From 8bc735eb23505bda446606171c98a7d683aecb11 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 21 Aug 2026 15:30:04 -0700 Subject: [PATCH 1/3] feat(angular): Vite HMR support with route, dialog, and cache preserve Vite client contract and keep the current URL, opt-in dialogs, compiled-component identities, and HmrCacheService across __reboot_ng_modules__ without walking a leftover back-stack. --- package-lock.json | 23 +- package.json | 2 +- packages/angular/src/lib/application.ts | 353 +++++++++++--- .../src/lib/cdk/dialog/dialog-config.ts | 5 + .../src/lib/cdk/dialog/dialog-hmr.spec.ts | 183 ++++++++ .../angular/src/lib/cdk/dialog/dialog-hmr.ts | 147 ++++++ .../src/lib/cdk/dialog/dialog-module.ts | 28 +- .../src/lib/cdk/dialog/dialog-services.ts | 225 ++++++++- .../lib/cdk/dialog/modal-host-props.spec.ts | 214 +++++++++ .../src/lib/cdk/dialog/modal-host-props.ts | 64 +++ .../src/lib/cdk/dialog/native-modal-ref.ts | 36 +- .../src/lib/element-registry/common-views.ts | 118 +++-- .../src/lib/element-registry/registry.ts | 4 +- packages/angular/src/lib/hmr-cache-store.ts | 130 ++++++ packages/angular/src/lib/hmr-cache.spec.ts | 43 ++ packages/angular/src/lib/hmr-cache.ts | 127 +++++ packages/angular/src/lib/hmr.spec.ts | 248 ++++++++++ packages/angular/src/lib/hmr.ts | 233 ++++++++++ .../src/lib/legacy/router/hmr-route.spec.ts | 333 ++++++++++++++ .../src/lib/legacy/router/hmr-route.ts | 435 ++++++++++++++++++ .../angular/src/lib/legacy/router/index.ts | 1 + .../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 | 37 +- .../angular/src/lib/nativescript-renderer.ts | 46 ++ .../angular/src/lib/platform-nativescript.ts | 10 +- packages/angular/src/lib/public_api.ts | 1 + .../src/lib/router/platform-location.ts | 7 +- packages/angular/src/lib/trace.ts | 9 + packages/angular/src/lib/view-util.ts | 17 +- packages/zone-js/dist/connectivity.ts | 2 +- packages/zone-js/dist/core.ts | 6 +- packages/zone-js/dist/events.ts | 8 +- packages/zone-js/dist/utils.ts | 40 +- packages/zone-js/dist/xhr.ts | 30 +- 37 files changed, 3192 insertions(+), 153 deletions(-) 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/cdk/dialog/modal-host-props.spec.ts create mode 100644 packages/angular/src/lib/cdk/dialog/modal-host-props.ts create mode 100644 packages/angular/src/lib/hmr-cache-store.ts create mode 100644 packages/angular/src/lib/hmr-cache.spec.ts create mode 100644 packages/angular/src/lib/hmr-cache.ts create mode 100644 packages/angular/src/lib/hmr.spec.ts create mode 100644 packages/angular/src/lib/hmr.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route.spec.ts create mode 100644 packages/angular/src/lib/legacy/router/hmr-route.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/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 f4eb37d8..a0d0365c 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -1,4 +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, @@ -11,13 +13,54 @@ 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 { + _hmrDiagBumpCycle, + getAngularCoreForHmrReset, + installAngularHmrComponentRegistrar, + isNativeScriptViteHmrActive, + rememberAngularCoreForHmr, + resetAngularHmrCompiledComponents, + runHmrEagerInstantiators, + setAngularCoreForHmr, +} from './hmr'; import { NativeScriptLoadingService } from './loading.service'; +import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route'; +import { NSLocationStrategy } from './legacy/router/ns-location-strategy'; +import { NSRouteReuseStrategy } from './legacy/router/ns-route-reuse-strategy'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; +rememberAngularCoreForHmr(AngularCore as any, globalThis as any); +installAngularHmrComponentRegistrar(); + +const angularHmrGlobal = globalThis as any; +angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => { + setAngularCoreForHmr(core, angularHmrGlobal); +}; + +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); + 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); + // Same-name HMR redefinition is not a real collision. + if (m && m[1] === m[2]) { + return; + } + } + origWarn(...args); + }; +} +installAngularNg0912HmrWarningFilter(); + export interface AppLaunchView extends LayoutBase { // called when the animation is to begin startAnimation?: () => void; @@ -48,7 +91,10 @@ export type NgModuleEvent = }; export const preAngularDisposal$ = new Subject(); -export const postAngularBootstrap$ = new Subject(); +/** + * Latest bootstrap event; ReplaySubject so late HMR subscribers still see it. + */ +export const postAngularBootstrap$ = new ReplaySubject(1); /** * @deprecated @@ -107,17 +153,35 @@ function emitModuleBootstrapEvent( name: 'main' | 'loading', reason: NgModuleReason, ) { + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`emitModuleBootstrapEvent name=${name} reason=${reason}`); + } + // Eager services must subscribe before this emit. + 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, }); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`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)'; + 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', @@ -133,6 +197,9 @@ function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name? }); } ref.destroy(); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`destroyRef DONE kind=${refKind} name=${name ?? '(none)'}`); + } } } @@ -220,10 +287,46 @@ 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 ( + clearedDetached > 0 || + cleared > 0 || + (clearedLocation && (clearedLocation.outlets > 0 || clearedLocation.states > 0 || clearedLocation.callbacks > 0 || clearedLocation.hadUrlTree)) + ) { + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`cleared Angular route caches before reboot: detachedViews=${clearedDetached} routeFields=${cleared} locationState=${JSON.stringify(clearedLocation)}`); + } + } + } catch { + // ignore + } + }; const updatePlatformRef = (moduleRef: NgModuleRef | ApplicationRef, reason: NgModuleReason) => { const newPlatformRef = moduleRef.injector.get(PlatformRef); if (newPlatformRef === platformRef) { @@ -235,17 +338,44 @@ 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 { + // ignore + } + }, 0); + }; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { + const traceEnabled = NativeScriptDebug.isLogEnabled(); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`setRootView called bootstrapId=${bootstrapId} refType=${ref?.constructor?.name}`); + } if (bootstrapId === -1) { - // treat edge cases + // 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)) { + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: DISABLE_ROOT_VIEW_HANDLING is true, returning'); + } return; } } else { if (ref['__disable_root_view_handling']) { + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: __disable_root_view_handling is true, returning'); + } return; } } @@ -253,13 +383,15 @@ 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) { - if (NativeScriptDebug.isLogEnabled()) { + if (traceEnabled) { + NativeScriptDebug.hmrLog(`setRootView: ref is View, launchEventDone=${launchEventDone}`); NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`); } - if (options.embedded) { + if (currentOptions.embedded) { Application.run({ create: () => ref }); } else if (launchEventDone) { Application.resetRootView({ create: () => ref }); + refreshRootViewCss(ref); } else { targetRootView = ref; } @@ -267,14 +399,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; - 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 (options.embedded) { + if (currentOptions.embedded) { + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: calling Application.run (embedded)'); + } Application.run({ create: () => newRoot }); } else if (launchEventDone) { + 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'}`, + ); + } Application.resetRootView({ create: () => newRoot }); + refreshRootViewCss(newRoot); + 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 { + if (traceEnabled) { + NativeScriptDebug.hmrLog('setRootView: setting targetRootView (launch in progress)'); + } targetRootView = newRoot; } }; @@ -286,8 +440,18 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { setRootView(errorTextBox); }; const bootstrapRoot = (reason: NgModuleReason) => { + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`bootstrapRoot called reason=${reason}`); + } try { + if (reason === 'hotreload') { + resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); + } + bootstrapId = Date.now(); + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`bootstrapRoot: new bootstrapId=${bootstrapId}`); + } const currentBootstrapId = bootstrapId; let bootstrapped = false; let onMainBootstrap = () => { @@ -295,28 +459,67 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }; runSynchronously( () => - options.appModuleBootstrap(reason).then( + currentOptions.appModuleBootstrap(reason).then( (ref) => { + 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 + // Stale bootstrap finished after a later reboot. + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog('bootstrap ID mismatch, destroying ref'); + } ref.destroy(); return; } - mainModuleRef = ref; - (ref instanceof ApplicationRef ? ref.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(() => { - removeTaggedAdditionalCSS(styleTag); + // HMR .then() runs outside NgZone; wrap when Zone exists. + const useZoneWrap = typeof Zone !== 'undefined' && !NgZone.isInAngularZone(); + const runInZone = (fn: () => void) => { + if (useZoneWrap) { + ref.injector.get(NgZone).run(fn); + } else { + fn(); + } + }; + runInZone(() => { + mainModuleRef = ref; + + // instanceof fails across HMR module realms. + const refAny = ref as any; + const isAppRef = refAny && typeof refAny.tick === 'function' && 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; + global['__NS_HMR_BOOT_COMPLETE__'] = true; + } else { + const appRef = ref.injector.get(ApplicationRef, null); + if (appRef) { + global['__NS_ANGULAR_APP_REF__'] = appRef; + 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); + }); + 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; @@ -330,9 +533,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 @@ -384,8 +587,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(() => { @@ -427,6 +630,10 @@ 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); @@ -450,49 +657,83 @@ 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); 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'); + + const isWebpackHot = !!import.meta['webpackHot']; + const isViteHot = isNativeScriptViteHmrActive() || !!import.meta['hot']; + + 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'); + }; + // Vite calls this before re-import so NG0912 does not fire. + global['__reset_ng_compiled_components__'] = () => { + resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); + }; + + global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { + const cycleNum = _hmrDiagBumpCycle(); + const traceEnabled = NativeScriptDebug.isLogEnabled(); + if (traceEnabled) { + NativeScriptDebug.hmrLog( + `__reboot_ng_modules__ called cycle=${cycleNum} shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`, + ); + } + try { + global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); + } catch { + // ignore + } + disposeLastModules('hotreload'); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); + } + if (shouldDisposePlatform) { disposePlatform('hotreload'); - }; - global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { - disposeLastModules('hotreload'); - if (shouldDisposePlatform) { - disposePlatform('hotreload'); - } - bootstrapRoot('hotreload'); - }; + } + if (traceEnabled) { + NativeScriptDebug.hmrLog(`calling bootstrapRoot cycle=${cycleNum}`); + } + bootstrapRoot('hotreload'); + if (traceEnabled) { + NativeScriptDebug.hmrLog(`bootstrapRoot returned cycle=${cycleNum} bootstrapId=${bootstrapId}`); + } + }; - if (!Application.hasLaunched()) { + // Embedded hosts already have a native loop; do not call Application.run(). + const runFirstLaunch = () => { + if (currentOptions.embedded) { + bootstrapRoot('applaunch'); + } else { Application.run(); + } + }; + + if (isWebpackHot || isViteHot) { + if (!Application.hasLaunched()) { + runFirstLaunch(); return; } bootstrapRoot('hotreload'); return; } - if (options.embedded) { - bootstrapRoot('applaunch'); - } else { - Application.run(); - } + runFirstLaunch(); } diff --git a/packages/angular/src/lib/cdk/dialog/dialog-config.ts b/packages/angular/src/lib/cdk/dialog/dialog-config.ts index ab47be0c..d8c89425 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-config.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-config.ts @@ -58,5 +58,10 @@ export class NativeDialogConfig { nativeOptions?: NativeShowModalOptions = {}; + /** + * Reopen this component dialog after HMR. TemplateRef openings are skipped. + */ + preserveOnHmr?: boolean = false; + // TODO(jelbourn): add configuration for lifecycle hooks, ARIA labelling. } 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..60a9f60a --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts @@ -0,0 +1,183 @@ +import { Subject } from 'rxjs'; +import { + abortCapturedDialog, + buildNonAnimatedRestoreConfig, + captureDialogsForHmr, + clearPendingHmrDialogs, + consumePendingHmrDialogs, + HmrCandidateDialog, + peekPendingHmrDialogs, + selectPreservableDialogs, + suppressNativeCloseAnimation, +} 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]); + }); + }); + + describe('animation helpers', () => { + function makeAnimatedCandidate(opts: { parentView?: { _modalAnimatedOptions?: boolean[] }; preserveOnHmr?: boolean }): HmrCandidateDialog { + const config = new NativeDialogConfig(); + config.preserveOnHmr = opts.preserveOnHmr ?? true; + return { + ref: { _nativeModalRef: opts.parentView ? { parentView: opts.parentView } : undefined } as any, + componentClass: StubComponent as any, + config, + }; + } + + it('flips only the top animated-stack entry for preservable dialogs', () => { + const stack = [true, true]; + suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: { _modalAnimatedOptions: stack } })); + expect(stack).toEqual([true, false]); + const skipped = [true]; + suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: { _modalAnimatedOptions: skipped }, preserveOnHmr: false })); + expect(skipped).toEqual([true]); + expect(() => suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: undefined }))).not.toThrow(); + }); + + it('clones restore config with animation forced off', () => { + const original = new NativeDialogConfig(); + original.id = 'resource-modal'; + original.data = { resourceId: 42 }; + original.preserveOnHmr = true; + original.nativeOptions = { animated: true, fullscreen: true } as never; + const restore = buildNonAnimatedRestoreConfig(original); + expect(restore).not.toBe(original); + expect((restore.nativeOptions as Record)?.animated).toBe(false); + expect((restore.nativeOptions as Record)?.fullscreen).toBe(true); + expect((original.nativeOptions as Record)?.animated).toBe(true); + expect(restore.id).toBe('resource-modal'); + expect(restore.preserveOnHmr).toBe(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..d32ec58c --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts @@ -0,0 +1,147 @@ +import { Subject } from 'rxjs'; +import { ComponentType } from '../../utils/general'; +import { NativeDialogConfig } from './dialog-config'; +import { NativeDialogRef } from './dialog-ref'; + +/** + * Dialog opening captured so it can reopen after an HMR reboot. + */ +export interface CapturedHmrDialog { + componentClass: ComponentType; + componentName: string; + config: NativeDialogConfig; + graftAfterClosed: (value: unknown) => void; +} + +const STASH_KEY = '__NS_ANGULAR_HMR_PENDING_MODALS__'; + +/** + * Filter open dialogs to component openings that opted into preserveOnHmr. + */ +export function selectPreservableDialogs(openDialogs: ReadonlyArray): HmrCandidateDialog[] { + return openDialogs.filter((dialog) => isPreservable(dialog)); +} + +export interface HmrCandidateDialog { + ref: NativeDialogRef; + componentClass?: ComponentType; + config: NativeDialogConfig; +} + +function isPreservable(dialog: HmrCandidateDialog): boolean { + if (!dialog.config?.preserveOnHmr) { + return false; + } + return typeof dialog.componentClass === 'function'; +} + +/** + * Stash opted-in open dialogs so the next realm can restore them. + */ +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); + // Name survives reboot; the class object may not. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const componentName = (componentClass! as unknown as { name?: string })?.name ?? ''; + return { + // 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 { + // ignore + } + }, + }; + }); + + (globalThis as unknown as Record)[STASH_KEY] = captures; + return captures; +} + +/** + * Drain the pending captures for restore. + */ +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'); +} + +/** + * Drop pending captures without restoring them. + */ +export function clearPendingHmrDialogs(): void { + delete (globalThis as unknown as Record)[STASH_KEY]; +} + +/** + * 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() : []; +} + +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; +} + +/** + * Release afterClosed waiters when a captured dialog cannot reopen. + */ +export function abortCapturedDialog(captured: CapturedHmrDialog): void { + try { + captured.graftAfterClosed(undefined); + } catch { + // ignore + } +} + +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 { + // ignore + } +} + +export function buildNonAnimatedRestoreConfig(original: NativeDialogConfig): NativeDialogConfig { + 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-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..6867924f 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -16,15 +16,36 @@ 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 { getFreshComponentClass, isAngularHmrEnabled, registerHmrEagerInstantiator } from '../../hmr'; 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, + buildNonAnimatedRestoreConfig, + captureDialogsForHmr, + CapturedHmrDialog, + consumePendingHmrDialogs, + HmrCandidateDialog, + peekPendingHmrDialogs, + suppressNativeCloseAnimation, +} from './dialog-hmr'; import { NativeDialogRef } from './dialog-ref'; import { NativeModalRef } from './native-modal-ref'; +function hmrDialogLog(message: string): void { + if (!isAngularHmrEnabled() || !NativeScriptDebug.isLogEnabled()) { + return; + } + NativeScriptDebug.hmrLog(`[dialog] ${message}`); +} + /** 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'); @@ -42,6 +63,8 @@ export class NativeDialog implements OnDestroy { private _openDialogsAtThisLevel: NativeDialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject>(); + 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 +99,7 @@ export class NativeDialog implements OnDestroy { private _nativeModalType = NativeModalRef; private _dialogDataToken = NATIVE_DIALOG_DATA; private locationStrategy = inject(NSLocationStrategy); + 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 +133,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); @@ -139,6 +167,190 @@ export class NativeDialog implements OnDestroy { this._closeDialogs(this._openDialogsAtThisLevel); this._afterAllClosedAtThisLevel.complete(); this._afterOpenedAtThisLevel.complete(); + for (const sub of this._hmrSubscriptions) { + try { + sub.unsubscribe(); + } catch { + // ignore + } + } + this._hmrSubscriptions = []; + } + + private _restoreScheduledForThisInstance = false; + + private _initHmrLifecycle(): null { + if (this._parentDialog || !isAngularHmrEnabled()) { + return null; + } + + const dispose = preAngularDisposal$.subscribe((event) => { + if (event.moduleType !== 'main' || event.reason !== 'hotreload') { + return; + } + this._captureOpenDialogsForHmr(); + }); + + const bootstrap = postAngularBootstrap$.subscribe((event) => { + if (event.moduleType !== 'main' || event.reason !== 'hotreload') { + return; + } + this._maybeScheduleRestore(`postAngularBootstrap$ (reason=${event.reason})`); + }); + + this._hmrSubscriptions.push(dispose, bootstrap); + + const pendingNow = peekPendingHmrDialogs(); + if (pendingNow.length > 0) { + this._maybeScheduleRestore(`stash peek on ctor: ${pendingNow.length} pending dialog(s)`); + } + return null; + } + + private _maybeScheduleRestore(triggerDescription: string): void { + if (this._restoreScheduledForThisInstance) { + return; + } + this._restoreScheduledForThisInstance = true; + hmrDialogLog(`scheduling restore (trigger=${triggerDescription})`); + 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(), + }; + }); + + const captured = captureDialogsForHmr(candidates); + + if (captured.length > 0) { + 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`); + } + } + + private async _restorePendingDialogs(): Promise { + const pending = consumePendingHmrDialogs(); + if (pending.length === 0) { + return; + } + + hmrDialogLog(`restoring ${pending.length} dialog(s) after reboot [${pending.map((c) => c.componentName).join(', ')}]`); + + for (const captured of pending) { + this._restoreSingleDialog(captured); + } + } + + private _restoreSingleDialog(captured: CapturedHmrDialog): void { + const live = getFreshComponentClass>(captured.componentName); + const componentClass = live ?? captured.componentClass; + const usingFresh = !!live && live !== captured.componentClass; + this._scheduleRestoreOpenWhenReady(captured, componentClass, usingFresh); + } + + private static readonly _ROOT_VIEW_LOADED_TIMEOUT_MS = 1_000; + + private _scheduleRestoreOpenWhenReady( + captured: CapturedHmrDialog, + componentClass: ComponentType, + usingFresh: boolean, + ): void { + const rootView = Application.getRootView(); + + if (rootView && rootView.isLoaded) { + // Yield so the incoming root view can attach first. + setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); + return; + } + + if (!rootView) { + 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 { + // ignore + } + // viewWillAppear runs before the view is in a window. + setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); + }; + + try { + rootView.once(View.loadedEvent, onLoaded); + } catch { + setTimeout(() => onLoaded(), 50); + } + + 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 { + if (usingFresh) { + hmrDialogLog(`restore ${captured.componentName} usingFreshClass=true`); + } + + 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}`); + NativeScriptDebug.hmrLogError(`HMR modal restore failed: ${message}`); + } } /** @@ -208,6 +420,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 +454,16 @@ function _applyConfigDefaults(config?: NativeDialogConfig, defaultOptions?: Nati return { ...defaultOptions, ...config }; } +if (isAngularHmrEnabled()) { + registerHmrEagerInstantiator((injector: Injector) => { + try { + injector.get(NativeDialog, null); + } catch { + // ignore + } + }); +} + export { /** * @deprecated Use `NativeDialog` instead. 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..9ccaadf2 --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts @@ -0,0 +1,214 @@ +import { AddViewHost, installPvcModalHostPropPropagation, ModalHostView, propagateModalHostPropsToDescendants, PVC_ADD_VIEW_WRAPPED_MARKER } from './modal-host-props'; + +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; + } + } + } + + _addView(view: ModalHostView, _atIndex?: number): void { + this.children.push(view as FakeView); + } +} + +describe('modal-host-props', () => { + describe('propagateModalHostPropsToDescendants', () => { + it('mirrors `_dialogFragment` and `viewController` from the wrapper onto every native-like descendant', () => { + 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', () => { + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + const sentinel = wrapper._dialogFragment; + + propagateModalHostPropsToDescendants(wrapper, wrapper); + + expect(wrapper._dialogFragment).toBe(sentinel); + }); + + it('no-ops when the modal has not been shown yet (wrapper has neither host prop)', () => { + 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)', () => { + 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)', () => { + 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)', () => { + 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', () => { + 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)', () => { + const dialogFragment = { kind: 'DialogFragment' }; + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = dialogFragment; + + 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', () => { + 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', () => { + 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)', () => { + 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', () => { + const wrapper = new FakeView('wrapper'); + wrapper._dialogFragment = { kind: 'A' }; + const pvc = new FakeView('pvc'); + installPvcModalHostPropPropagation(pvc, wrapper); + + 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..2dd5bf1f --- /dev/null +++ b/packages/angular/src/lib/cdk/dialog/modal-host-props.ts @@ -0,0 +1,64 @@ +export interface ModalHostView { + _dialogFragment?: unknown; + viewController?: unknown; + eachChildView?: (callback: (child: ModalHostView) => boolean) => void; +} + +export interface AddViewHost extends ModalHostView { + _addView?: (view: ModalHostView, atIndex?: number) => void; +} + +export const PVC_ADD_VIEW_WRAPPED_MARKER = '__ng_modal_propagate_addview__'; + +/** + * Copy wrapper modal-host props onto every descendant of root. + */ +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); +} + +/** + * Wrap host._addView so later children receive wrapper modal-host props. + */ +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) => { + // loaded fires inside _addView; props must exist first. + 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 e2a8f6de..d98f49fd 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; @@ -20,6 +21,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 +63,13 @@ export class NativeModalRef { this._closeCallback = once(async () => { this.stateChanged.next({ state: 'closing' }); if (!this._isDismissed) { - this.modalViewRef.firstNativeLikeView?.closeModal(); + // Prefer the presented wrapper; HMR may replace the first root. + 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 +130,23 @@ export class NativeModalRef { attachComponentPortal(portal: ComponentPortal): ComponentRef { this.startModalNavigation(); + // Present a stable wrapper so HMR can replace the template root. 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(); + 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, @@ -145,6 +161,13 @@ export class NativeModalRef { if (!didModalOpen(this.parentView, modalView)) { this._handleFailedOpen(); } + + 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; } @@ -168,6 +191,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/element-registry/common-views.ts b/packages/angular/src/lib/element-registry/common-views.ts index f4436ec9..42516533 100644 --- a/packages/angular/src/lib/element-registry/common-views.ts +++ b/packages/angular/src/lib/element-registry/common-views.ts @@ -1,49 +1,83 @@ -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); - } + 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..f1312d4e 100644 --- a/packages/angular/src/lib/element-registry/registry.ts +++ b/packages/angular/src/lib/element-registry/registry.ts @@ -4,7 +4,9 @@ import { ViewClassMeta } from '../views/view-types'; export type ViewResolver = () => any; -export const elementMap = new Map(); +// Share one registry across HMR 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/hmr-cache-store.ts b/packages/angular/src/lib/hmr-cache-store.ts new file mode 100644 index 00000000..7069af33 --- /dev/null +++ b/packages/angular/src/lib/hmr-cache-store.ts @@ -0,0 +1,130 @@ +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; + clear(): void; + size(): number; +} + +export class HmrCacheStore { + private readonly _map: Map; + private readonly _maxEntries: number; + private readonly _log: (message: string) => void; + + constructor(initialEntries: Iterable<[string, unknown]> = [], options: { maxEntries?: number; log?: (message: string) => void } = {}) { + this._map = new Map(initialEntries); + const requested = options.maxEntries; + this._maxEntries = typeof requested === 'number' && requested > 0 ? Math.floor(requested) : 0; + this._log = options.log ?? (() => undefined); + this._enforceMaxEntries(); + } + + get(key: string): T | undefined { + if (!this._map.has(key)) { + return undefined; + } + 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)) { + 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); + } + + invalidate(key?: string): void { + if (key === undefined || key === null) { + this.clear(); + return; + } + this.delete(key); + } + + clear(): void { + this._map.clear(); + } + + size(): number { + return this._map.size; + } + + keys(): string[] { + return Array.from(this._map.keys()); + } + + scope(prefix: string): HmrCacheScope { + if (!prefix) { + throw new Error('[HmrCache] 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; + }, + }; + } + + 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(`[HmrCache] evicted oldest key="${oldestKey}" (size now ${this._map.size}/${this._maxEntries})`); + } + } +} diff --git a/packages/angular/src/lib/hmr-cache.spec.ts b/packages/angular/src/lib/hmr-cache.spec.ts new file mode 100644 index 00000000..9fc9505c --- /dev/null +++ b/packages/angular/src/lib/hmr-cache.spec.ts @@ -0,0 +1,43 @@ +import { HmrCacheStore } from './hmr-cache-store'; + +describe('HmrCacheStore', () => { + it('get/set/has/delete and LRU-evicts oldest entries', () => { + const store = new HmrCacheStore([], { maxEntries: 2 }); + store.set('a', 1); + store.set('b', 2); + expect(store.get('a')).toBe(1); + store.set('c', 3); + expect(store.has('b')).toBe(false); + expect(store.keys()).toEqual(['a', 'c']); + store.delete('a'); + expect(store.size()).toBe(1); + store.invalidate(); + expect(store.size()).toBe(0); + }); + + it('scopes keys and can clear only that prefix', () => { + const store = new HmrCacheStore(); + const page = store.scope('page'); + page.set('items', [1]); + store.set('other', true); + expect(page.get('items')).toEqual([1]); + expect(store.get('page:items')).toEqual([1]); + expect(page.size()).toBe(1); + page.clear(); + expect(store.has('other')).toBe(true); + expect(page.has('items')).toBe(false); + expect(() => store.scope('')).toThrow(); + }); + + it('trims a seed that already exceeds maxEntries', () => { + const store = new HmrCacheStore( + [ + ['one', 1], + ['two', 2], + ['three', 3], + ], + { maxEntries: 2 }, + ); + expect(store.keys()).toEqual(['two', 'three']); + }); +}); diff --git a/packages/angular/src/lib/hmr-cache.ts b/packages/angular/src/lib/hmr-cache.ts new file mode 100644 index 00000000..96c97fb7 --- /dev/null +++ b/packages/angular/src/lib/hmr-cache.ts @@ -0,0 +1,127 @@ +import { Injectable } from '@angular/core'; +import { isAngularHmrEnabled } from './hmr'; +import { HmrCacheScope, HmrCacheStore } from './hmr-cache-store'; +import { NativeScriptDebug } from './trace'; + +export type { HmrCacheScope } from './hmr-cache-store'; + +const DEFAULT_MAX_ENTRIES = 256; +const STORAGE_KEY = 'ns-hmr-cache'; +const INVALIDATE_EVENT = 'ns:cache-invalidate'; + +interface NsHotContext { + readonly data: Record; + dispose: (cb: (data: Record) => void) => void; + on?: (event: string, cb: (payload: unknown) => void) => void; +} + +function readImportMetaHot(): NsHotContext | undefined { + try { + return (import.meta as unknown as { hot?: NsHotContext })['hot']; + } catch { + return undefined; + } +} + +export interface HmrCacheOptions { + maxEntries?: number; +} + +function createSharedStore(options: HmrCacheOptions & { log?: (message: string) => void } = {}): HmrCacheStore { + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + const log = options.log ?? (() => undefined); + const hot = readImportMetaHot(); + const previousSnapshot = ((hot?.data as Record | undefined)?.[STORAGE_KEY] ?? {}) as Record; + const store = new HmrCacheStore(Object.entries(previousSnapshot), { maxEntries, log }); + + if (hot) { + hot.dispose((data) => { + (data as Record)[STORAGE_KEY] = store.toObject(); + }); + if (typeof hot.on === 'function') { + hot.on(INVALIDATE_EVENT, (payload: { key?: string } | undefined) => { + store.invalidate(payload?.key); + }); + } + } + + return store; +} + +let sharedStore: HmrCacheStore | null = null; +let pendingOptions: HmrCacheOptions | null = null; + +function getOrCreateSharedStore(): HmrCacheStore { + if (sharedStore !== null) { + return sharedStore; + } + sharedStore = createSharedStore({ + log: (msg) => { + try { + NativeScriptDebug.bootstrapLog(msg); + } catch { + // ignore + } + }, + ...pendingOptions, + }); + pendingOptions = null; + return sharedStore; +} + +/** + * Set cache options before the first HmrCacheService inject. + * @returns true if stored, false if the store already exists. + */ +export function configureHmrCache(options: HmrCacheOptions): boolean { + if (sharedStore !== null) { + return false; + } + pendingOptions = options; + return true; +} + +/** + * Per-app key/value cache that survives `__reboot_ng_modules__`. + */ +@Injectable({ providedIn: 'root' }) +export class HmrCacheService { + private readonly _store = getOrCreateSharedStore(); + readonly isHmr: boolean = isAngularHmrEnabled() && !!readImportMetaHot(); + + 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); + } + + invalidate(key?: string): void { + this._store.invalidate(key); + } + + clear(): void { + this._store.clear(); + } + + size(): number { + return this._store.size(); + } + + keys(): string[] { + return this._store.keys(); + } + + scope(scopeName: string): HmrCacheScope { + return this._store.scope(scopeName); + } +} diff --git a/packages/angular/src/lib/hmr.spec.ts b/packages/angular/src/lib/hmr.spec.ts new file mode 100644 index 00000000..e4b68937 --- /dev/null +++ b/packages/angular/src/lib/hmr.spec.ts @@ -0,0 +1,248 @@ +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, + _registerComponentForHmr, + clearAngularHmrClassRegistry, + clearHmrEagerInstantiators, + getAngularCoreForHmrReset, + getFreshComponentClass, + getRegisteredHmrEagerInstantiators, + HmrEagerInstantiator, + installAngularHmrComponentRegistrar, + isAngularDevMode, + isAngularHmrEnabled, + isNativeScriptViteHmrActive, + isWebpackHmrActive, + registerHmrEagerInstantiator, + rememberAngularCoreForHmr, + resetAngularHmrCompiledComponents, + runHmrEagerInstantiators, + setAngularCoreForHmr, +} from './hmr'; + +interface MutableGlobal { + ngDevMode?: boolean; + __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; + __NS_HMR_BOOT_COMPLETE__?: unknown; + __webpack_require__?: unknown; +} + +const HOOK_KEY = '__NS_HMR_REGISTER_COMPONENT__'; + +describe('hmr environment', () => { + const g = globalThis as unknown as MutableGlobal; + 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; + } + }); + + it('treats undefined ngDevMode as dev', () => { + delete g.ngDevMode; + expect(isAngularDevMode()).toBe(true); + }); + + it('returns false in production even when HMR flags are set', () => { + 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('detects the Vite early placeholder and the post-boot complete flag', () => { + delete g.ngDevMode; + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + delete g.__NS_HMR_BOOT_COMPLETE__; + delete g.__webpack_require__; + expect(isNativeScriptViteHmrActive()).toBe(false); + + g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; + expect(isNativeScriptViteHmrActive()).toBe(true); + expect(isAngularHmrEnabled()).toBe(true); + + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + g.__NS_HMR_BOOT_COMPLETE__ = true; + expect(isNativeScriptViteHmrActive()).toBe(true); + expect(isAngularHmrEnabled()).toBe(true); + }); + + it('detects webpack HMR via __webpack_require__', () => { + delete g.ngDevMode; + delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; + delete g.__NS_HMR_BOOT_COMPLETE__; + delete g.__webpack_require__; + expect(isWebpackHmrActive()).toBe(false); + expect(isAngularHmrEnabled()).toBe(false); + + g.__webpack_require__ = () => undefined; + expect(isWebpackHmrActive()).toBe(true); + expect(isAngularHmrEnabled()).toBe(true); + + g.__webpack_require__ = 'not-a-function' as unknown as object; + expect(isWebpackHmrActive()).toBe(false); + }); +}); + +describe('hmr class registry', () => { + afterEach(() => { + clearAngularHmrClassRegistry(); + }); + + it('installs the registrar unconditionally and is idempotent', () => { + installAngularHmrComponentRegistrar(); + const first = (globalThis as Record)[HOOK_KEY]; + expect(typeof first).toBe('function'); + installAngularHmrComponentRegistrar(); + expect((globalThis as Record)[HOOK_KEY]).toBe(first); + }); + + it('keeps the latest class identity across reboot registrations', () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as (name: string, cls: unknown) => void; + class FooV1 {} + class FooV2 {} + hook('FooComponent', FooV1); + expect(getFreshComponentClass('FooComponent')).toBe(FooV1); + hook('FooComponent', FooV2); + expect(getFreshComponentClass('FooComponent')).toBe(FooV2); + }); + + it('skips invalid registrations and empty lookups', () => { + class Foo {} + _registerComponentForHmr('', Foo); + _registerComponentForHmr('Foo', null); + expect(getFreshComponentClass('')).toBeUndefined(); + expect(getFreshComponentClass('Foo')).toBeUndefined(); + expect(getFreshComponentClass('NeverRegistered')).toBeUndefined(); + }); + + it('survives a simulated reboot via the same global hook', () => { + installAngularHmrComponentRegistrar(); + const hook = (globalThis as Record)[HOOK_KEY] as (name: string, cls: unknown) => void; + class CycleOne {} + class CycleTwo {} + _hmrDiagBumpCycle(); + hook('ResourceModalComponent', CycleOne); + expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleOne); + _hmrDiagBumpCycle(); + hook('ResourceModalComponent', CycleTwo); + expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleTwo); + expect(_hmrDiagSnapshot().registerCalls).toBe(2); + }); +}); + +describe('hmr eager services', () => { + beforeEach(() => { + clearHmrEagerInstantiators(); + }); + + it('registers callbacks once and runs them with the injector', () => { + const calls: unknown[] = []; + const fn: HmrEagerInstantiator = (injector) => calls.push(injector); + expect(registerHmrEagerInstantiator(fn)).toBe(true); + expect(registerHmrEagerInstantiator(fn)).toBe(false); + expect(registerHmrEagerInstantiator('nope' as unknown as HmrEagerInstantiator)).toBe(false); + const injector = { id: 'fake' } as unknown as Parameters[0]; + runHmrEagerInstantiators(injector); + expect(calls).toEqual([injector]); + expect(getRegisteredHmrEagerInstantiators()).toEqual([fn]); + }); + + it('continues after a callback throws and ignores reporter failures', () => { + 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[0] as Error).message).toBe('boom'); + + clearHmrEagerInstantiators(); + 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).toContain('second ran'); + }); + + it('is a no-op without an injector', () => { + expect(() => runHmrEagerInstantiators(undefined)).not.toThrow(); + expect(() => runHmrEagerInstantiators(null)).not.toThrow(); + }); +}); + +describe('hmr compiled component reset', () => { + it('calls Angular reset when available and swallows failures', () => { + const core = { ɵresetCompiledComponents: jest.fn() }; + expect(resetAngularHmrCompiledComponents(core)).toBe(true); + expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1); + expect(resetAngularHmrCompiledComponents({})).toBe(false); + const exploding = { + ɵresetCompiledComponents: jest.fn(() => { + throw new Error('boom'); + }), + }; + expect(resetAngularHmrCompiledComponents(exploding)).toBe(false); + }); + + it('remembers the first Angular core and allows an explicit replace', () => { + const originalCore = { ɵresetCompiledComponents: jest.fn() }; + const replacementCore = { ɵresetCompiledComponents: jest.fn() }; + const globalObj: any = {}; + expect(rememberAngularCoreForHmr(originalCore, globalObj)).toBe(originalCore); + expect(rememberAngularCoreForHmr(replacementCore, globalObj)).toBe(originalCore); + expect(getAngularCoreForHmrReset(replacementCore, globalObj)).toBe(originalCore); + expect(setAngularCoreForHmr(replacementCore, globalObj)).toBe(replacementCore); + }); +}); diff --git a/packages/angular/src/lib/hmr.ts b/packages/angular/src/lib/hmr.ts new file mode 100644 index 00000000..394faec1 --- /dev/null +++ b/packages/angular/src/lib/hmr.ts @@ -0,0 +1,233 @@ +import type { Injector } from '@angular/core'; +import { NativeScriptDebug } from './trace'; + +declare const ngDevMode: boolean | undefined; + +type AngularCoreWithCompiledComponentReset = { + ɵresetCompiledComponents?: () => void; +}; + +type AngularCoreHolder = { + __NS_ANGULAR_CORE__?: AngularCoreWithCompiledComponentReset | null; +}; + +export function isAngularDevMode(): boolean { + if (typeof ngDevMode === 'undefined') { + return true; + } + return ngDevMode !== false; +} + +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__); +} + +export function isWebpackHmrActive(): boolean { + return typeof (globalThis as { __webpack_require__?: unknown }).__webpack_require__ === 'function'; +} + +/** + * True in Angular dev when Vite or webpack HMR is active. + */ +export function isAngularHmrEnabled(): boolean { + if (typeof ngDevMode !== 'undefined' && ngDevMode === false) { + return false; + } + return isNativeScriptViteHmrActive() || isWebpackHmrActive(); +} + +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, +): 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 { + const resetCompiledComponents = core?.ɵresetCompiledComponents; + if (typeof resetCompiledComponents !== 'function') { + return false; + } + try { + resetCompiledComponents.call(core); + return true; + } catch { + return false; + } +} + +const REGISTRY_KEY = '__NS_ANGULAR_HMR_CLASS_REGISTRY__'; +const REGISTRAR_HOOK = '__NS_HMR_REGISTER_COMPONENT__'; +const REGISTRAR_INSTALLED_FLAG = '__NS_ANGULAR_HMR_REGISTRAR_INSTALLED__'; +const DIAG_KEY = '__NS_HMR_DIAG__'; + +type HmrClassRegistry = Map; + +interface DiagSlot { + [DIAG_KEY]?: { + cycle: number; + registerCalls: number; + }; +} + +interface GlobalRegistrySlot { + [REGISTRY_KEY]?: HmrClassRegistry; + [REGISTRAR_INSTALLED_FLAG]?: boolean; + [REGISTRAR_HOOK]?: (name: string, cls: unknown, url?: string) => void; +} + +function getDiag() { + const slot = globalThis as unknown as DiagSlot; + if (!slot[DIAG_KEY]) { + slot[DIAG_KEY] = { cycle: 0, registerCalls: 0 }; + } + return slot[DIAG_KEY]!; +} + +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; +} + +export function _registerComponentForHmr(name: string, cls: unknown, _url = ''): void { + if (!name || typeof name !== 'string') { + return; + } + if (cls === undefined || cls === null) { + return; + } + getRegistry().set(name, cls); + getDiag().registerCalls += 1; +} + +/** + * Install `__NS_HMR_REGISTER_COMPONENT__` for Vite class identity updates. + */ +export function installAngularHmrComponentRegistrar(): void { + const slot = globalThis as unknown as GlobalRegistrySlot; + if (slot[REGISTRAR_INSTALLED_FLAG]) { + return; + } + slot[REGISTRAR_HOOK] = (name: string, cls: unknown, url?: string) => { + try { + _registerComponentForHmr(name, cls, typeof url === 'string' ? url : ''); + } catch (err) { + if (isAngularHmrEnabled() && NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(`[class-registry] registrar threw for ${name}: ${(err as Error)?.message ?? err}`); + } + } + }; + slot[REGISTRAR_INSTALLED_FLAG] = true; +} + +export function getFreshComponentClass(name: string): T | undefined { + if (!name) { + return undefined; + } + const registry = (globalThis as unknown as GlobalRegistrySlot)[REGISTRY_KEY]; + return registry?.get(name) as T | undefined; +} + +export function _hmrDiagBumpCycle(): number { + const diag = getDiag(); + diag.cycle += 1; + installAngularHmrComponentRegistrar(); + return diag.cycle; +} + +export function _hmrDiagSnapshot(): { cycle: number; registerCalls: number } { + const diag = getDiag(); + return { cycle: diag.cycle, registerCalls: diag.registerCalls }; +} + +export function clearAngularHmrClassRegistry(): void { + const slot = globalThis as unknown as GlobalRegistrySlot; + slot[REGISTRY_KEY] = undefined; + slot[REGISTRAR_INSTALLED_FLAG] = undefined; + slot[REGISTRAR_HOOK] = undefined; + (globalThis as unknown as DiagSlot)[DIAG_KEY] = undefined; +} + +export type HmrEagerInstantiator = (injector: Injector) => void; + +const EAGER_REGISTRY_KEY = '__NS_HMR_EAGER_SERVICES__'; + +interface HmrEagerGlobals { + [EAGER_REGISTRY_KEY]?: HmrEagerInstantiator[]; +} + +export function getRegisteredHmrEagerInstantiators(): HmrEagerInstantiator[] { + const store = globalThis as unknown as HmrEagerGlobals; + const list = store[EAGER_REGISTRY_KEY]; + if (!Array.isArray(list)) { + const fresh: HmrEagerInstantiator[] = []; + store[EAGER_REGISTRY_KEY] = fresh; + return fresh; + } + return list; +} + +export function registerHmrEagerInstantiator(fn: HmrEagerInstantiator): boolean { + if (typeof fn !== 'function') { + return false; + } + const list = getRegisteredHmrEagerInstantiators(); + if (list.includes(fn)) { + return false; + } + list.push(fn); + return true; +} + +export function clearHmrEagerInstantiators(): void { + getRegisteredHmrEagerInstantiators().length = 0; +} + +export function runHmrEagerInstantiators(injector: Injector | null | undefined, onError?: (err: unknown) => void): void { + if (!injector) { + return; + } + const list = getRegisteredHmrEagerInstantiators(); + for (let i = 0; i < list.length; i++) { + try { + list[i](injector); + } catch (err) { + if (onError) { + try { + onError(err); + } catch { + // ignore + } + } + } + } +} diff --git a/packages/angular/src/lib/legacy/router/hmr-route.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route.spec.ts new file mode 100644 index 00000000..0d9fe3da --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route.spec.ts @@ -0,0 +1,333 @@ +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, + ) {} +} +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', () => ({ + NavigationStart: MockNavigationStart, + NavigationEnd: MockNavigationEnd, + NavigationCancel: MockNavigationCancel, + NavigationError: MockNavigationError, + Router: class {}, +})); + +jest.mock('../../trace', () => ({ + NativeScriptDebug: { + isLogEnabled: () => false, + hmrLog: jest.fn(), + }, +})); + +jest.mock('../../hmr', () => ({ + isAngularHmrEnabled: () => true, +})); + +import { Subject } from 'rxjs'; +import { + beginAngularHmrRouteRestore, + captureAngularHmrPendingStartPath, + clearAngularHmrRouteConfigCaches, + cloneRoutesForBootstrap, + endAngularHmrRouteRestore, + getAngularHmrRestoringRoute, + installAngularHmrRouteCaptureHook, + invokeAngularHmrRouteCapture, + isAngularHmrRestoringRoute, + NativeScriptAngularHmrRouteReplay, + NativeScriptAngularHmrRouteTracker, + normalizeAngularHmrRouteUrl, + readAngularHmrCurrentRoute, + readAngularHmrPendingForwardNavigations, + readAngularHmrPendingStartPath, + resetAngularHmrRouteState, + writeAngularHmrRouteState, +} from './hmr-route'; + +interface RouterEvent { + url?: string; +} + +interface RouterMock { + events: Subject; + url: string; + navigateByUrl: jest.Mock, [string]>; + emitNavigationEnd(url: string): void; + emitNavigationCancel(url: string): void; +} + +function createRouterMock(initialUrl = '/'): RouterMock { + const events = new Subject(); + return { + events, + url: initialUrl, + navigateByUrl: jest.fn, [string]>(() => Promise.resolve(true)), + emitNavigationEnd(url) { + this.url = url; + events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent); + }, + emitNavigationCancel(url) { + events.next(new MockNavigationCancel(1, url, 'cancel') as unknown as RouterEvent); + }, + }; +} + +async function flushMicrotasks(rounds = 10): Promise { + for (let i = 0; i < rounds; i++) { + await Promise.resolve(); + } +} + +describe('Angular HMR route state', () => { + afterEach(() => { + resetAngularHmrRouteState(); + }); + + 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 start path and opens the restoring window', () => { + captureAngularHmrPendingStartPath('chatbot/42?mode=create'); + expect(readAngularHmrPendingStartPath()).toBe('/chatbot/42?mode=create'); + expect(isAngularHmrRestoringRoute()).toBe(true); + expect(getAngularHmrRestoringRoute()).toBe('/chatbot/42?mode=create'); + expect(readAngularHmrPendingForwardNavigations()).toEqual([]); + }); + + it('uses the capture hook before falling back to the last current URL', () => { + 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(); + } + writeAngularHmrRouteState('/profile?tab=goals', { source: 'navigation-end' }); + expect(invokeAngularHmrRouteCapture()).toBe('/profile?tab=goals'); + }); + + it('boots named-outlet URLs at / and defers one forward navigation', () => { + captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); + expect(readAngularHmrPendingStartPath()).toBe('/'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); + expect(isAngularHmrRestoringRoute()).toBe(true); + expect(getAngularHmrRestoringRoute()).toBe('/talk/(todayTab:today)'); + }); + + it('does not open the restoring window when nothing is pending', () => { + expect(readAngularHmrPendingStartPath()).toBe(''); + expect(isAngularHmrRestoringRoute()).toBe(false); + }); + + it('opens and closes the restoring window on demand', () => { + beginAngularHmrRouteRestore('/profile?tab=goals'); + expect(isAngularHmrRestoringRoute()).toBe(true); + endAngularHmrRouteRestore(); + expect(isAngularHmrRestoringRoute()).toBe(false); + expect(getAngularHmrRestoringRoute()).toBeNull(); + }); +}); + +describe('cloneRoutesForBootstrap / route cache clear', () => { + it('drops private Angular router cache fields while preserving public 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[0].loadComponent).toBe(loadComponent); + expect(cloned[0].canActivate).not.toBe(canActivate); + expect(cloned[0]._loadedComponent).toBeUndefined(); + expect(cloned[0]['ɵrouterPageId']).toBeUndefined(); + expect(cloned[0].children[0]._loadedComponent).toBeUndefined(); + expect(cloneRoutesForBootstrap(undefined)).toEqual([]); + }); + + it('clears lazy route caches recursively without looping on shared children', () => { + const detailsInjectorDestroy = jest.fn(); + const grandchild = { + path: 'details', + _loadedComponent: { name: 'DetailsComponent' }, + _loadedInjector: { token: 'details', destroy: detailsInjectorDestroy }, + }; + const shared = { + path: 'shared', + _loadedComponent: { name: 'SharedComponent' }, + }; + const route = { + path: 'onboarding-flow', + children: [grandchild], + _loadedRoutes: [shared], + }; + const cleared = clearAngularHmrRouteConfigCaches([route, { path: 'b', _loadedRoutes: [shared] }]); + expect(cleared).toBe(5); + expect(detailsInjectorDestroy).toHaveBeenCalledTimes(1); + expect((shared as any)._loadedComponent).toBeUndefined(); + }); +}); + +describe('NativeScriptAngularHmrRouteTracker', () => { + afterEach(() => { + resetAngularHmrRouteState(); + }); + + it('does not treat bootstrap "/" as a pending restore URL', () => { + const router = createRouterMock('/'); + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + expect(readAngularHmrPendingStartPath()).toBe(''); + router.emitNavigationEnd('/talk/(todayTab:today)'); + expect(readAngularHmrCurrentRoute()).toBe('/talk/(todayTab:today)'); + tracker.ngOnDestroy(); + }); + + it('writes the current URL on NavigationEnd and captures it for reboot', () => { + const router = createRouterMock('/'); + const tracker = new NativeScriptAngularHmrRouteTracker(router as never); + router.emitNavigationEnd('/signup-landing'); + router.emitNavigationEnd('/login'); + router.emitNavigationEnd('/talk/(todayTab:today)'); + expect(readAngularHmrCurrentRoute()).toBe('/talk/(todayTab:today)'); + + const captureHook = (globalThis as { __NS_CAPTURE_ANGULAR_HMR_ROUTE__?: () => string | null }).__NS_CAPTURE_ANGULAR_HMR_ROUTE__; + expect(captureHook?.()).toBe('/talk/(todayTab:today)'); + expect(readAngularHmrPendingStartPath()).toBe('/'); + expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); + tracker.ngOnDestroy(); + }); +}); + +describe('NativeScriptAngularHmrRouteReplay', () => { + beforeEach(() => { + jest.useFakeTimers(); + resetAngularHmrRouteState(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + resetAngularHmrRouteState(); + }); + + it('keeps the restoring window open during the grace period after a named-outlet replay', async () => { + captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); + expect(readAngularHmrPendingStartPath()).toBe('/'); + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationEnd('/'); + await flushMicrotasks(); + await flushMicrotasks(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/talk/(todayTab:today)'); + 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 deferred replay aborts', async () => { + captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); + readAngularHmrPendingStartPath(); + const router = createRouterMock(); + router.navigateByUrl.mockImplementation(() => Promise.resolve(false)); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationEnd('/'); + await flushMicrotasks(); + await flushMicrotasks(); + await flushMicrotasks(); + expect(isAngularHmrRestoringRoute()).toBe(true); + jest.advanceTimersByTime(1000); + expect(isAngularHmrRestoringRoute()).toBe(false); + replay.ngOnDestroy(); + }); + + it('keeps a single-URL restore window open after the initial NavigationEnd', () => { + beginAngularHmrRouteRestore('/profile?tab=goals'); + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationEnd('/profile?tab=goals'); + expect(isAngularHmrRestoringRoute()).toBe(true); + jest.advanceTimersByTime(1000); + expect(isAngularHmrRestoringRoute()).toBe(false); + replay.ngOnDestroy(); + }); + + it('clears the deferred close timer when the service is destroyed', async () => { + captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); + readAngularHmrPendingStartPath(); + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationEnd('/'); + await flushMicrotasks(); + await flushMicrotasks(); + replay.ngOnDestroy(); + expect(isAngularHmrRestoringRoute()).toBe(false); + beginAngularHmrRouteRestore('/somewhere/else'); + jest.advanceTimersByTime(2000); + expect(isAngularHmrRestoringRoute()).toBe(true); + endAngularHmrRouteRestore(); + }); + + it('closes the window immediately when the initial navigation fails', () => { + captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); + readAngularHmrPendingStartPath(); + const router = createRouterMock(); + const replay = new NativeScriptAngularHmrRouteReplay(router as any); + router.emitNavigationCancel('/'); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(isAngularHmrRestoringRoute()).toBe(false); + replay.ngOnDestroy(); + }); +}); diff --git a/packages/angular/src/lib/legacy/router/hmr-route.ts b/packages/angular/src/lib/legacy/router/hmr-route.ts new file mode 100644 index 00000000..4b8580e4 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/hmr-route.ts @@ -0,0 +1,435 @@ +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'; +import { NativeScriptDebug } from '../../trace'; + +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 PENDING_FORWARD_KEY = '__NS_ANGULAR_HMR_PENDING_FORWARD__'; +const CAPTURE_ROUTE_KEY = '__NS_CAPTURE_ANGULAR_HMR_ROUTE__'; +const RESTORING_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE__'; +const RESTORING_TARGET_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE_TARGET__'; +const REPLAY_COMPLETED_GRACE_MS = 1000; + +function getGlobalState(): any { + return globalThis as any; +} + +function hasNamedOutletsInUrl(url: string): boolean { + return typeof url === 'string' && /\([A-Za-z0-9_-]+:/.test(url); +} + +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 readAngularHmrCurrentRoute(): string { + const g = getGlobalState(); + return normalizeAngularHmrRouteUrl(g[CURRENT_ROUTE_KEY]?.url ?? g[CURRENT_ROUTE_KEY]) || ''; +} + +function readPendingStartUrl(): string { + const g = getGlobalState(); + return normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; +} + +function writePendingForward(url: string | null): void { + const g = getGlobalState(); + if (url) { + g[PENDING_FORWARD_KEY] = url; + } else { + delete g[PENDING_FORWARD_KEY]; + } +} + +export function readAngularHmrPendingStartPath(): string { + const target = readPendingStartUrl(); + if (!target) { + return ''; + } + beginAngularHmrRouteRestore(target); + if (hasNamedOutletsInUrl(target)) { + writePendingForward(target); + return '/'; + } + return target; +} + +export function readAngularHmrPendingForwardNavigations(): string[] { + const pending = getGlobalState()[PENDING_FORWARD_KEY]; + const target = typeof pending === 'string' ? pending : ''; + if (target && hasNamedOutletsInUrl(target)) { + return [target]; + } + const start = readPendingStartUrl(); + if (start && hasNamedOutletsInUrl(start)) { + return [start]; + } + return []; +} + +export function clearAngularHmrPendingForwardNavigation(): void { + delete getGlobalState()[PENDING_FORWARD_KEY]; +} + +export function invokeAngularHmrRouteCapture(): string | null { + const g = getGlobalState(); + const capture = g[CAPTURE_ROUTE_KEY]; + if (typeof capture === 'function') { + try { + return capture(); + } catch { + // ignore + } + } + 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]; + } + }; +} + +/** + * True while HMR is restoring the captured current URL. + */ +export function isAngularHmrRestoringRoute(): boolean { + return getGlobalState()[RESTORING_KEY] === true; +} + +/** + * Target URL currently being restored, or `null` when idle. + */ +export function getAngularHmrRestoringRoute(): string | null { + const value = getGlobalState()[RESTORING_TARGET_KEY]; + return typeof value === 'string' && value ? value : null; +} + +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]; + } +} + +export function endAngularHmrRouteRestore(): void { + const g = getGlobalState(); + delete g[RESTORING_KEY]; + delete g[RESTORING_TARGET_KEY]; +} + +export function resetAngularHmrRouteState(): void { + const g = getGlobalState(); + delete g[CURRENT_ROUTE_KEY]; + delete g[PENDING_START_PATH_KEY]; + delete g[PENDING_FORWARD_KEY]; + delete g[CAPTURE_ROUTE_KEY]; + endAngularHmrRouteRestore(); +} + +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 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 (key.startsWith('_') || key.startsWith('ɵ')) { + 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)); +} + +type AngularHmrRouteLike = { + children?: AngularHmrRouteLike[]; + _injector?: unknown; + _loadedComponent?: unknown; + _loadedInjector?: unknown; + _loadedNgModuleFactory?: unknown; + _loadedRoutes?: AngularHmrRouteLike[]; +}; + +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 { + // ignore + } + } +} + +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 { + try { + route[key] = undefined; + } catch { + // ignore + } + } + 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; +} + +@Injectable() +export class NativeScriptAngularHmrRouteTracker implements OnDestroy { + private subscription?: Subscription; + private disposeCaptureHook?: () => void; + + constructor(private readonly router: Router) { + if (!isAngularHmrEnabled()) { + return; + } + this.disposeCaptureHook = installAngularHmrRouteCaptureHook(() => this.captureCurrentRoute('hmr-reboot')); + 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, + }); + } +} + +@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(); + const restoringWindowOpen = isAngularHmrRestoringRoute(); + + if (forwardNavigations.length === 0) { + clearAngularHmrPendingForwardNavigation(); + if (restoringWindowOpen) { + 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')); + this.windowFallbackTimeout = setTimeout(() => this.closeRestoringWindow('fallback-timeout'), 5000); + } + return; + } + + 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.replayForwardNavigation(forwardNavigations[0]); + } else { + clearAngularHmrPendingForwardNavigation(); + this.closeRestoringWindow('initial-navigation-failed'); + } + }); + + 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; + } + 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})`); + } + } + + 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 replayForwardNavigation(url: string): Promise { + let aborted = false; + try { + const succeeded = await this.router.navigateByUrl(url).catch(() => false); + if (!succeeded) { + aborted = true; + } + } finally { + clearAngularHmrPendingForwardNavigation(); + this.scheduleRestoringWindowClose(aborted ? 'replay-aborted' : 'replay-completed'); + } + } +} diff --git a/packages/angular/src/lib/legacy/router/index.ts b/packages/angular/src/lib/legacy/router/index.ts index 52cf4562..b8f6bd47 100644 --- a/packages/angular/src/lib/legacy/router/index.ts +++ b/packages/angular/src/lib/legacy/router/index.ts @@ -1,3 +1,4 @@ export { NSLocationStrategy } from './ns-location-strategy'; export { NSRouteReuseStrategy } from './ns-route-reuse-strategy'; export * from './router.module'; +export { getAngularHmrRestoringRoute, isAngularHmrRestoringRoute } from './hmr-route'; 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 b0171695..6a9c0f9f 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 { cloneRoutesForBootstrap, NativeScriptAngularHmrRouteReplay, NativeScriptAngularHmrRouteTracker, readAngularHmrPendingStartPath } from './hmr-route'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; export { PageRoute } from './page-router-outlet'; @@ -73,7 +77,11 @@ export class NativeScriptRouterModule { return { ngModule: NativeScriptRouterModule, providers: [ - ...RouterModule.forRoot(routes, config).providers, + ...RouterModule.forRoot(cloneRoutesForBootstrap(routes), config).providers, + { + provide: START_PATH, + useFactory: readAngularHmrPendingStartPath, + }, { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -85,6 +93,14 @@ export class NativeScriptRouterModule { RouterExtensions, NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, + NativeScriptAngularHmrRouteTracker, + NativeScriptAngularHmrRouteReplay, + { + provide: APP_BOOTSTRAP_LISTENER, + multi: true, + deps: [NativeScriptAngularHmrRouteTracker, NativeScriptAngularHmrRouteReplay], + useFactory: () => () => undefined, + }, config?.bindToComponentInputs ? inputBinderProviders(typeof config.bindToComponentInputs === 'object' ? config.bindToComponentInputs : {}) : [], @@ -93,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 { @@ -103,7 +119,11 @@ 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, + }, { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -115,6 +135,17 @@ export function provideNativeScriptRouter(routes: Routes, ...features: RouterFea RouterExtensions, 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}, hasInputBinding ? inputBinderProviders() : [], ]); } diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 03ef3af8..80f7537a 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -107,6 +107,7 @@ function modifiesDom() { export class NativeScriptRendererFactory implements RendererFactory2 { private componentRenderers = new Map(); + private componentStyleSignatures = new Map(); private defaultRenderer: Renderer2; // backwards compatibility with RadListView private rootView = inject(APP_ROOT_VIEW); @@ -152,6 +153,17 @@ export class NativeScriptRendererFactory implements RendererFactory2 { renderer.applyToHost(hostElement); } + // Cached renderer skips addStyles; re-apply when styles changed. + 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 +178,28 @@ export class NativeScriptRendererFactory implements RendererFactory2 { } this.componentRenderers.set(type.id, renderer); + this.componentStyleSignatures.set(type.id, this.styleSignature(type.styles)); return renderer; } + + private styleSignature(styles: (string | any[])[]): string { + try { + return (styles || []).map((s) => s.toString()).join("\n"); + } catch { + return ''; + } + } + + 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 +519,31 @@ 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 emulated styles after an HMR style edit. + */ + reapplyStyles(styles: (string | any[])[]): void { + this.addStyles(styles, this.componentId); + try { + Application.getRootView()?._onCssStateChange(); + } catch { + // ignore + } + } + applyToHost(view: NgView) { super.setAttribute(view, this.hostAttr, ''); } diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index 3989a1ff..cabef5e6 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)); } @@ -252,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/public_api.ts b/packages/angular/src/lib/public_api.ts index fa617f87..18a45fb9 100644 --- a/packages/angular/src/lib/public_api.ts +++ b/packages/angular/src/lib/public_api.ts @@ -42,6 +42,7 @@ export * from './file-system'; export * from './nativescript-common.module'; export * from './loading.service'; export * from './detached-loader-utils'; +export { HmrCacheService, configureHmrCache, type HmrCacheOptions, type HmrCacheScope } from './hmr-cache'; // export * from './router/router.module'; export { AppLaunchView, 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 diff --git a/packages/zone-js/dist/connectivity.ts b/packages/zone-js/dist/connectivity.ts index 8da43200..4eb078c6 100644 --- a/packages/zone-js/dist/connectivity.ts +++ b/packages/zone-js/dist/connectivity.ts @@ -10,6 +10,6 @@ Zone.__load_patch('nativescript_connectivity', (global, zone, api) => { 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 index 8b0d1c02..c590e8e3 100644 --- a/packages/zone-js/dist/core.ts +++ b/packages/zone-js/dist/core.ts @@ -14,7 +14,11 @@ function isPropertyWritable(propertyDesc: any) { } 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 { + 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); diff --git a/packages/zone-js/dist/events.ts b/packages/zone-js/dist/events.ts index 824c5b6c..b51d20a7 100644 --- a/packages/zone-js/dist/events.ts +++ b/packages/zone-js/dist/events.ts @@ -19,7 +19,7 @@ Zone.__load_patch('nativescript_mainThreadify', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS mainThreadify patch')]); - } + }, ); }); @@ -31,7 +31,7 @@ Zone.__load_patch('nativescript_executeOnMainThread', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS executeOnMainThread patch')]); - } + }, ); }); @@ -43,7 +43,7 @@ Zone.__load_patch('nativescript_dispatchToMainThread', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS dispatchToMainThread patch')]); - } + }, ); }); @@ -62,7 +62,7 @@ Zone.__load_patch('nativescript_showModal', (global, zone, api) => { args[3] = Zone.current.wrap(args[3], 'NS showModal patch'); } return delegate.apply(self, args); - } + }, ); }); diff --git a/packages/zone-js/dist/utils.ts b/packages/zone-js/dist/utils.ts index 84df0d6b..d477c105 100644 --- a/packages/zone-js/dist/utils.ts +++ b/packages/zone-js/dist/utils.ts @@ -58,7 +58,12 @@ export interface PatchEventTargetOptions { transferEventName?: (eventName: string) => string; } -export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, apis?: any[], patchOptions?: PatchEventTargetOptions) { +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'; @@ -149,7 +154,13 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api nsTaskData: taskData, }; const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask(objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), callback, data, schedule, unschedule); + 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; @@ -174,7 +185,7 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api } else { addSingleEvent(originalTarget, originalArgs); } - } + }, ); const nativeOnce = api.patchMethod( @@ -218,7 +229,11 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api 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); + task.target[REMOVE_EVENT_LISTENER]( + task.eventName, + task.callback, + task.thisArg ? task.thisArg.get() : undefined, + ); }; const args2 = [taskData.eventName, task.invoke]; if (taskData.thisArg) { @@ -240,7 +255,13 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api nsTaskData: taskData, }; const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask(objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), callback, data, schedule, unschedule); + 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; @@ -255,7 +276,10 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api task.eventName = eventName; existingTasks.push(task); }; - const events: string[] = originalArgs && Array.isArray(originalArgs) && typeof originalArgs[0] === 'string' ? originalArgs[0].split(',') : []; + 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); @@ -266,7 +290,7 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api } else { addSingleEvent(originalTarget, originalArgs); } - } + }, ); const nativeRemoveListener = api.patchMethod( @@ -320,7 +344,7 @@ export function patchNativeScriptEventTarget(global: any, api: _ZonePrivate, api } else { removeSingleEvent(originalTarget, originalArgs); } - } + }, ); } diff --git a/packages/zone-js/dist/xhr.ts b/packages/zone-js/dist/xhr.ts index 2e6c13ab..29bdd731 100644 --- a/packages/zone-js/dist/xhr.ts +++ b/packages/zone-js/dist/xhr.ts @@ -14,7 +14,13 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr /** 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 { + 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. @@ -142,7 +148,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr self[XHR_SYNC] = args[2] == false; self[XHR_URL] = args[1]; return openNative!.apply(self, args); - } + }, ); const XMLHTTPREQUEST_SOURCE = 'XMLHttpRequest.send'; @@ -163,8 +169,20 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr // 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); + 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 @@ -172,7 +190,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr task.invoke(); } } - } + }, ); const abortNative = api.patchMethod( @@ -197,7 +215,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no // task // to cancel. Do nothing. - } + }, ); } }); From 7e31a9180324e75a4abfbeb5aada4c7caba0f0f5 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 28 Aug 2026 18:45:32 -0700 Subject: [PATCH 2/3] fix(angular): await unloaded on the presented modal wrapper during dismissal --- packages/angular/src/lib/cdk/dialog/native-modal-ref.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f6248b35..759a0c57 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -165,7 +165,8 @@ export class NativeModalRef { // Core tears the view down right after this callback returns, so the listener has to be // attached synchronously, before any await. - const modalView = this.modalViewRef?.firstNativeLikeView; + // Prefer the presented wrapper; HMR may replace the first root. + const modalView = this.modalView ?? this.modalViewRef?.firstNativeLikeView; const whenUnloaded = modalView?.isLoaded ? new Promise((resolve) => modalView.once(View.unloadedEvent, () => resolve())) : Promise.resolve(); await this.location?._closeModalNavigation(); From 442ff1e1e0a53122734b95bd3dbf2ae93eaa7a7e Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 2 Oct 2026 13:50:53 -0700 Subject: [PATCH 3/3] refactor(angular): consolidate HMR into lib/hmr and strip it from release builds All HMR support now lives in packages/angular/src/lib/hmr (hmr, route, dialog, modal-host, cache). Every entry point outside that folder is guarded by `typeof ngDevMode === 'undefined' || ngDevMode`, so release builds (which define ngDevMode=false) drop it entirely; verified against iOS and Android release bundles of a Vite app. - Features register beforeDispose/afterBootstrap hooks with the reboot instead of application.ts importing the router and NativeDialog subscribing to bootstrap events; removes the eager-instantiator registry and the ReplaySubject change. - Route restore captures router.url at dispose and feeds the start path through provideLocationStrategy instead of overriding START_PATH, so an app-provided START_PATH is no longer clobbered. Route configs are no longer cloned at bootstrap; router caches are cleared at dispose instead. - Dialog restore tracks preserveOnHmr dialogs, drops the destroyed viewContainerRef/injector, and waits for app stability so an edited dialog inside a lazy route reopens with its fresh class. - The modal ContentView wrapper and host-prop mirroring apply only in live HMR sessions; release and plain dev builds present modals exactly as before. - Drops the console.warn NG0912 filter, Angular-core namespace holder, diagnostics counters, redundant boot-complete globals and the import.meta.hot wiring in HmrCacheService (import.meta.hot is always undefined inside the package). - Reverts formatting-only zone-js changes, the NativeDialogModule provider removal, the element registration changes and the NSLocationStrategy and NSRouteReuseStrategy ngOnDestroy changes. --- packages/angular/src/lib/application.ts | 360 +++------------ .../src/lib/cdk/dialog/dialog-hmr.spec.ts | 183 -------- .../angular/src/lib/cdk/dialog/dialog-hmr.ts | 147 ------ .../src/lib/cdk/dialog/dialog-module.ts | 28 +- .../src/lib/cdk/dialog/dialog-services.ts | 229 +-------- .../lib/cdk/dialog/modal-host-props.spec.ts | 214 --------- .../src/lib/cdk/dialog/modal-host-props.ts | 64 --- .../src/lib/cdk/dialog/native-modal-ref.ts | 46 +- .../src/lib/element-registry/common-views.ts | 118 ++--- packages/angular/src/lib/hmr-cache-store.ts | 130 ------ packages/angular/src/lib/hmr-cache.spec.ts | 43 -- packages/angular/src/lib/hmr-cache.ts | 127 ----- packages/angular/src/lib/hmr.spec.ts | 248 ---------- packages/angular/src/lib/hmr.ts | 233 ---------- packages/angular/src/lib/hmr/cache.spec.ts | 46 ++ packages/angular/src/lib/hmr/cache.ts | 116 +++++ packages/angular/src/lib/hmr/dialog.spec.ts | 85 ++++ packages/angular/src/lib/hmr/dialog.ts | 109 +++++ packages/angular/src/lib/hmr/hmr.spec.ts | 90 ++++ packages/angular/src/lib/hmr/hmr.ts | 107 +++++ .../angular/src/lib/hmr/modal-host.spec.ts | 71 +++ packages/angular/src/lib/hmr/modal-host.ts | 54 +++ packages/angular/src/lib/hmr/route.spec.ts | 139 ++++++ packages/angular/src/lib/hmr/route.ts | 139 ++++++ .../src/lib/legacy/router/hmr-route.spec.ts | 333 -------------- .../src/lib/legacy/router/hmr-route.ts | 435 ------------------ .../angular/src/lib/legacy/router/index.ts | 2 +- .../router/ns-location-strategy.spec.ts | 81 ---- .../lib/legacy/router/ns-location-strategy.ts | 23 +- .../router/ns-route-reuse-strategy.spec.ts | 22 +- .../legacy/router/ns-route-reuse-strategy.ts | 21 +- .../src/lib/legacy/router/router.module.ts | 47 +- .../angular/src/lib/nativescript-renderer.ts | 75 ++- .../angular/src/lib/platform-nativescript.ts | 10 +- packages/angular/src/lib/public_api.ts | 2 +- packages/angular/src/lib/view-util.ts | 4 +- packages/zone-js/dist/connectivity.ts | 2 +- packages/zone-js/dist/core.ts | 6 +- packages/zone-js/dist/events.ts | 8 +- packages/zone-js/dist/utils.ts | 40 +- packages/zone-js/dist/xhr.ts | 30 +- 41 files changed, 1179 insertions(+), 3088 deletions(-) delete mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts delete mode 100644 packages/angular/src/lib/cdk/dialog/dialog-hmr.ts delete mode 100644 packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts delete mode 100644 packages/angular/src/lib/cdk/dialog/modal-host-props.ts delete mode 100644 packages/angular/src/lib/hmr-cache-store.ts delete mode 100644 packages/angular/src/lib/hmr-cache.spec.ts delete mode 100644 packages/angular/src/lib/hmr-cache.ts delete mode 100644 packages/angular/src/lib/hmr.spec.ts delete mode 100644 packages/angular/src/lib/hmr.ts create mode 100644 packages/angular/src/lib/hmr/cache.spec.ts create mode 100644 packages/angular/src/lib/hmr/cache.ts create mode 100644 packages/angular/src/lib/hmr/dialog.spec.ts create mode 100644 packages/angular/src/lib/hmr/dialog.ts create mode 100644 packages/angular/src/lib/hmr/hmr.spec.ts create mode 100644 packages/angular/src/lib/hmr/hmr.ts create mode 100644 packages/angular/src/lib/hmr/modal-host.spec.ts create mode 100644 packages/angular/src/lib/hmr/modal-host.ts create mode 100644 packages/angular/src/lib/hmr/route.spec.ts create mode 100644 packages/angular/src/lib/hmr/route.ts delete mode 100644 packages/angular/src/lib/legacy/router/hmr-route.spec.ts delete mode 100644 packages/angular/src/lib/legacy/router/hmr-route.ts delete mode 100644 packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts diff --git a/packages/angular/src/lib/application.ts b/packages/angular/src/lib/application.ts index a0d0365c..a8efc1de 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -1,6 +1,4 @@ -import * as AngularCore from '@angular/core'; import { ApplicationRef, EnvironmentProviders, NgModuleRef, NgZone, PlatformRef, Provider } from '@angular/core'; -import { Router } from '@angular/router'; import { Application, ApplicationEventData, @@ -13,54 +11,14 @@ import { Utils, View, } from '@nativescript/core'; -import { Observable, ReplaySubject, Subject } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { AppHostView } from './app-host-view'; -import { - _hmrDiagBumpCycle, - getAngularCoreForHmrReset, - installAngularHmrComponentRegistrar, - isNativeScriptViteHmrActive, - rememberAngularCoreForHmr, - resetAngularHmrCompiledComponents, - runHmrEagerInstantiators, - setAngularCoreForHmr, -} from './hmr'; +import { handOffHmrAppOptions, hmrLog, installViteHmrGlobals, runHmrHooks } from './hmr/hmr'; import { NativeScriptLoadingService } from './loading.service'; -import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route'; -import { NSLocationStrategy } from './legacy/router/ns-location-strategy'; -import { NSRouteReuseStrategy } from './legacy/router/ns-route-reuse-strategy'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; -rememberAngularCoreForHmr(AngularCore as any, globalThis as any); -installAngularHmrComponentRegistrar(); - -const angularHmrGlobal = globalThis as any; -angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => { - setAngularCoreForHmr(core, angularHmrGlobal); -}; - -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); - 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); - // Same-name HMR redefinition is not a real collision. - if (m && m[1] === m[2]) { - return; - } - } - origWarn(...args); - }; -} -installAngularNg0912HmrWarningFilter(); - export interface AppLaunchView extends LayoutBase { // called when the animation is to begin startAnimation?: () => void; @@ -91,10 +49,7 @@ export type NgModuleEvent = }; export const preAngularDisposal$ = new Subject(); -/** - * Latest bootstrap event; ReplaySubject so late HMR subscribers still see it. - */ -export const postAngularBootstrap$ = new ReplaySubject(1); +export const postAngularBootstrap$ = new Subject(); /** * @deprecated @@ -153,35 +108,17 @@ function emitModuleBootstrapEvent( name: 'main' | 'loading', reason: NgModuleReason, ) { - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`emitModuleBootstrapEvent name=${name} reason=${reason}`); - } - // Eager services must subscribe before this emit. - 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, }); - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`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)'; - 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', @@ -197,9 +134,6 @@ function destroyRef(ref: PlatformRef | ApplicationRef | NgModuleRef, name? }); } ref.destroy(); - if (traceEnabled) { - NativeScriptDebug.hmrLog(`destroyRef DONE kind=${refKind} name=${name ?? '(none)'}`); - } } } @@ -287,46 +221,13 @@ 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); + if ((typeof ngDevMode === 'undefined' || ngDevMode) && handOffHmrAppOptions(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 ( - clearedDetached > 0 || - cleared > 0 || - (clearedLocation && (clearedLocation.outlets > 0 || clearedLocation.states > 0 || clearedLocation.callbacks > 0 || clearedLocation.hadUrlTree)) - ) { - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`cleared Angular route caches before reboot: detachedViews=${clearedDetached} routeFields=${cleared} locationState=${JSON.stringify(clearedLocation)}`); - } - } - } catch { - // ignore - } - }; const updatePlatformRef = (moduleRef: NgModuleRef | ApplicationRef, reason: NgModuleReason) => { const newPlatformRef = moduleRef.injector.get(PlatformRef); if (newPlatformRef === platformRef) { @@ -338,44 +239,17 @@ 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 { - // ignore - } - }, 0); - }; const setRootView = (ref: NgModuleRef | ApplicationRef | View) => { - const traceEnabled = NativeScriptDebug.isLogEnabled(); - if (traceEnabled) { - NativeScriptDebug.hmrLog(`setRootView called bootstrapId=${bootstrapId} refType=${ref?.constructor?.name}`); - } if (bootstrapId === -1) { - // edge case: a stale ref racing with a teardown - if (traceEnabled) { - NativeScriptDebug.hmrLog('setRootView: bootstrapId is -1, returning early'); - } + // treat edge cases return; } if (ref instanceof NgModuleRef || ref instanceof ApplicationRef) { if (ref.injector.get(DISABLE_ROOT_VIEW_HANDLING, false)) { - if (traceEnabled) { - NativeScriptDebug.hmrLog('setRootView: DISABLE_ROOT_VIEW_HANDLING is true, returning'); - } return; } } else { if (ref['__disable_root_view_handling']) { - if (traceEnabled) { - NativeScriptDebug.hmrLog('setRootView: __disable_root_view_handling is true, returning'); - } return; } } @@ -383,15 +257,13 @@ 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) { - if (traceEnabled) { - NativeScriptDebug.hmrLog(`setRootView: ref is View, launchEventDone=${launchEventDone}`); + if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`); } - if (currentOptions.embedded) { + if (options.embedded) { Application.run({ create: () => ref }); } else if (launchEventDone) { Application.resetRootView({ create: () => ref }); - refreshRootViewCss(ref); } else { targetRootView = ref; } @@ -399,36 +271,18 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { } const view = ref.injector.get(APP_ROOT_VIEW) as AppHostView | View; const newRoot = view instanceof AppHostView ? view.content : view; - if (traceEnabled) { - NativeScriptDebug.hmrLog(`setRootView: view=${view?.constructor?.name} newRoot=${newRoot?.constructor?.name} launchEventDone=${launchEventDone} embedded=${!!currentOptions.embedded}`); + if (NativeScriptDebug.isLogEnabled()) { NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`); } - if (currentOptions.embedded) { - if (traceEnabled) { - NativeScriptDebug.hmrLog('setRootView: calling Application.run (embedded)'); - } + if (options.embedded) { Application.run({ create: () => newRoot }); } else if (launchEventDone) { - 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'}`, - ); - } Application.resetRootView({ create: () => newRoot }); - refreshRootViewCss(newRoot); - 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); + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // A reboot's root is built before its component CSS is re-added; restyle it once attached. + setTimeout(() => Application.getRootView() === newRoot && newRoot._onCssStateChange()); } } else { - if (traceEnabled) { - NativeScriptDebug.hmrLog('setRootView: setting targetRootView (launch in progress)'); - } targetRootView = newRoot; } }; @@ -440,18 +294,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { setRootView(errorTextBox); }; const bootstrapRoot = (reason: NgModuleReason) => { - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`bootstrapRoot called reason=${reason}`); - } try { - if (reason === 'hotreload') { - resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); - } - bootstrapId = Date.now(); - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`bootstrapRoot: new bootstrapId=${bootstrapId}`); - } const currentBootstrapId = bootstrapId; let bootstrapped = false; let onMainBootstrap = () => { @@ -459,67 +303,45 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }; runSynchronously( () => - currentOptions.appModuleBootstrap(reason).then( + options.appModuleBootstrap(reason).then( (ref) => { - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog( - `appModuleBootstrap resolved ref=${ref?.constructor?.name} currentBootstrapId=${currentBootstrapId} bootstrapId=${bootstrapId}`, - ); - } if (currentBootstrapId !== bootstrapId) { - // Stale bootstrap finished after a later reboot. - if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog('bootstrap ID mismatch, destroying ref'); - } + // 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 ref.destroy(); return; } - - // HMR .then() runs outside NgZone; wrap when Zone exists. - const useZoneWrap = typeof Zone !== 'undefined' && !NgZone.isInAngularZone(); - const runInZone = (fn: () => void) => { - if (useZoneWrap) { - ref.injector.get(NgZone).run(fn); - } else { - fn(); - } - }; - runInZone(() => { + const onBootstrapped = () => { mainModuleRef = ref; - // instanceof fails across HMR module realms. - const refAny = ref as any; - const isAppRef = refAny && typeof refAny.tick === 'function' && 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; - global['__NS_HMR_BOOT_COMPLETE__'] = true; - } else { - const appRef = ref.injector.get(ApplicationRef, null); - if (appRef) { - global['__NS_ANGULAR_APP_REF__'] = appRef; - global['__NS_HMR_BOOT_COMPLETE__'] = true; - } - } - - (isAppRef ? refAny.components[0] : ref).onDestroy( + (ref instanceof ApplicationRef ? ref.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(() => { + (ref instanceof ApplicationRef ? ref.components[0] : ref).onDestroy(() => { removeTaggedAdditionalCSS(styleTag); }); bootstrapped = true; onMainBootstrap(); emitModuleBootstrapEvent(ref, 'main', reason); // bootstrapped component: (ref as any)._bootstrapComponents[0]; - }); + }; + if ((typeof ngDevMode === 'undefined' || ngDevMode) && reason === 'hotreload') { + // A reboot resolves outside the Angular zone; run the new app's setup inside it. + const zone = typeof Zone !== 'undefined' && !NgZone.isInAngularZone() ? ref.injector.get(NgZone) : null; + const afterReboot = () => { + onBootstrapped(); + runHmrHooks('afterBootstrap', ref.injector); + }; + if (zone) { + zone.run(afterReboot); + } else { + afterReboot(); + } + } else { + onBootstrapped(); + } }, (err) => { bootstrapped = true; @@ -533,9 +355,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { return; } if (!bootstrapped) { - if (currentOptions.loadingModule) { + if (options.loadingModule) { runSynchronously(() => - currentOptions.loadingModule(reason).then( + options.loadingModule(reason).then( (loadingRef) => { if (currentBootstrapId !== bootstrapId) { // this module is old and not needed anymore @@ -587,8 +409,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { }, ), ); - } else if (currentOptions.launchView) { - let launchView = currentOptions.launchView(reason); + } else if (options.launchView) { + let launchView = options.launchView(reason); setRootView(launchView); if (launchView.startAnimation) { setTimeout(() => { @@ -630,10 +452,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { platformRef = null; }; const disposeLastModules = (reason: NgModuleReason) => { - if (reason === 'hotreload') { - clearAngularHmrRouteCaches(); + if ((typeof ngDevMode === 'undefined' || ngDevMode) && reason === 'hotreload') { + runHmrHooks('beforeDispose', mainModuleRef?.injector); } - // reset bootstrap ID to make sure any modules bootstrapped after this are discarded bootstrapId = -1; destroyRef(loadingModuleRef, 'loading', reason); @@ -657,83 +478,50 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { global.NativeScriptGlobals.events.addEventListener = global.NativeScriptGlobals.events[Zone.__symbol__('addEventListener')]; } - if (!currentOptions.embedded) { + if (!options.embedded) { Application.on(Application.launchEvent, launchCallback); } Application.on(Application.exitEvent, exitCallback); if (oldAddEventListener) { global.NativeScriptGlobals.events.addEventListener = oldAddEventListener; } - - const isWebpackHot = !!import.meta['webpackHot']; - const isViteHot = isNativeScriptViteHmrActive() || !!import.meta['hot']; - - 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'); - }; - // Vite calls this before re-import so NG0912 does not fire. - global['__reset_ng_compiled_components__'] = () => { - resetAngularHmrCompiledComponents(getAngularCoreForHmrReset(AngularCore as any, globalThis as any)); - }; - - global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => { - const cycleNum = _hmrDiagBumpCycle(); - const traceEnabled = NativeScriptDebug.isLogEnabled(); - if (traceEnabled) { - NativeScriptDebug.hmrLog( - `__reboot_ng_modules__ called cycle=${cycleNum} shouldDisposePlatform=${shouldDisposePlatform} bootstrapId=${bootstrapId} hasMainModuleRef=${!!mainModuleRef}`, - ); - } - try { - global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.(); - } catch { - // ignore - } - disposeLastModules('hotreload'); - if (traceEnabled) { - NativeScriptDebug.hmrLog(`after disposeLastModules cycle=${cycleNum} bootstrapId=${bootstrapId}`); - } - if (shouldDisposePlatform) { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Hooks for webpack and @nativescript/vite HMR. + global['__dispose_app_ng_platform__'] = () => { disposePlatform('hotreload'); - } - if (traceEnabled) { - NativeScriptDebug.hmrLog(`calling bootstrapRoot cycle=${cycleNum}`); - } - bootstrapRoot('hotreload'); - if (traceEnabled) { - NativeScriptDebug.hmrLog(`bootstrapRoot returned cycle=${cycleNum} bootstrapId=${bootstrapId}`); - } - }; - - // Embedded hosts already have a native loop; do not call Application.run(). - const runFirstLaunch = () => { - if (currentOptions.embedded) { - bootstrapRoot('applaunch'); - } else { - Application.run(); - } - }; + }; + 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) => { + hmrLog(`rebooting Angular (disposePlatform=${shouldDisposePlatform})`); + disposeLastModules('hotreload'); + if (shouldDisposePlatform) { + disposePlatform('hotreload'); + } + bootstrapRoot('hotreload'); + }; + installViteHmrGlobals((next: AppRunOptions) => (options = next)); - if (isWebpackHot || isViteHot) { - if (!Application.hasLaunched()) { - runFirstLaunch(); + // Already launched means HMR re-ran the entry (webpack) or Vite's placeholder root launched the app first. + if (Application.hasLaunched()) { + bootstrapRoot('hotreload'); return; } - bootstrapRoot('hotreload'); - return; } - runFirstLaunch(); + if (options.embedded) { + bootstrapRoot('applaunch'); + } else { + Application.run(); + } } diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts deleted file mode 100644 index 60a9f60a..00000000 --- a/packages/angular/src/lib/cdk/dialog/dialog-hmr.spec.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { Subject } from 'rxjs'; -import { - abortCapturedDialog, - buildNonAnimatedRestoreConfig, - captureDialogsForHmr, - clearPendingHmrDialogs, - consumePendingHmrDialogs, - HmrCandidateDialog, - peekPendingHmrDialogs, - selectPreservableDialogs, - suppressNativeCloseAnimation, -} 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]); - }); - }); - - describe('animation helpers', () => { - function makeAnimatedCandidate(opts: { parentView?: { _modalAnimatedOptions?: boolean[] }; preserveOnHmr?: boolean }): HmrCandidateDialog { - const config = new NativeDialogConfig(); - config.preserveOnHmr = opts.preserveOnHmr ?? true; - return { - ref: { _nativeModalRef: opts.parentView ? { parentView: opts.parentView } : undefined } as any, - componentClass: StubComponent as any, - config, - }; - } - - it('flips only the top animated-stack entry for preservable dialogs', () => { - const stack = [true, true]; - suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: { _modalAnimatedOptions: stack } })); - expect(stack).toEqual([true, false]); - const skipped = [true]; - suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: { _modalAnimatedOptions: skipped }, preserveOnHmr: false })); - expect(skipped).toEqual([true]); - expect(() => suppressNativeCloseAnimation(makeAnimatedCandidate({ parentView: undefined }))).not.toThrow(); - }); - - it('clones restore config with animation forced off', () => { - const original = new NativeDialogConfig(); - original.id = 'resource-modal'; - original.data = { resourceId: 42 }; - original.preserveOnHmr = true; - original.nativeOptions = { animated: true, fullscreen: true } as never; - const restore = buildNonAnimatedRestoreConfig(original); - expect(restore).not.toBe(original); - expect((restore.nativeOptions as Record)?.animated).toBe(false); - expect((restore.nativeOptions as Record)?.fullscreen).toBe(true); - expect((original.nativeOptions as Record)?.animated).toBe(true); - expect(restore.id).toBe('resource-modal'); - expect(restore.preserveOnHmr).toBe(true); - }); - }); -}); diff --git a/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts b/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts deleted file mode 100644 index d32ec58c..00000000 --- a/packages/angular/src/lib/cdk/dialog/dialog-hmr.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Subject } from 'rxjs'; -import { ComponentType } from '../../utils/general'; -import { NativeDialogConfig } from './dialog-config'; -import { NativeDialogRef } from './dialog-ref'; - -/** - * Dialog opening captured so it can reopen after an HMR reboot. - */ -export interface CapturedHmrDialog { - componentClass: ComponentType; - componentName: string; - config: NativeDialogConfig; - graftAfterClosed: (value: unknown) => void; -} - -const STASH_KEY = '__NS_ANGULAR_HMR_PENDING_MODALS__'; - -/** - * Filter open dialogs to component openings that opted into preserveOnHmr. - */ -export function selectPreservableDialogs(openDialogs: ReadonlyArray): HmrCandidateDialog[] { - return openDialogs.filter((dialog) => isPreservable(dialog)); -} - -export interface HmrCandidateDialog { - ref: NativeDialogRef; - componentClass?: ComponentType; - config: NativeDialogConfig; -} - -function isPreservable(dialog: HmrCandidateDialog): boolean { - if (!dialog.config?.preserveOnHmr) { - return false; - } - return typeof dialog.componentClass === 'function'; -} - -/** - * Stash opted-in open dialogs so the next realm can restore them. - */ -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); - // Name survives reboot; the class object may not. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const componentName = (componentClass! as unknown as { name?: string })?.name ?? ''; - return { - // 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 { - // ignore - } - }, - }; - }); - - (globalThis as unknown as Record)[STASH_KEY] = captures; - return captures; -} - -/** - * Drain the pending captures for restore. - */ -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'); -} - -/** - * Drop pending captures without restoring them. - */ -export function clearPendingHmrDialogs(): void { - delete (globalThis as unknown as Record)[STASH_KEY]; -} - -/** - * 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() : []; -} - -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; -} - -/** - * Release afterClosed waiters when a captured dialog cannot reopen. - */ -export function abortCapturedDialog(captured: CapturedHmrDialog): void { - try { - captured.graftAfterClosed(undefined); - } catch { - // ignore - } -} - -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 { - // ignore - } -} - -export function buildNonAnimatedRestoreConfig(original: NativeDialogConfig): NativeDialogConfig { - 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-module.ts b/packages/angular/src/lib/cdk/dialog/dialog-module.ts index 4d5aacee..19e411e2 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-module.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-module.ts @@ -1,34 +1,10 @@ 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 6867924f..3c3caf08 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -16,36 +16,16 @@ import { TemplateRef, Type, } from '@angular/core'; -import { Application, View } from '@nativescript/core'; -import { defer, Observable, Subject, Subscription } from 'rxjs'; +import { defer, Observable, Subject } from 'rxjs'; import { startWith } from 'rxjs/operators'; -import { postAngularBootstrap$, preAngularDisposal$ } from '../../application'; -import { getFreshComponentClass, isAngularHmrEnabled, registerHmrEagerInstantiator } from '../../hmr'; +import { trackHmrDialog } from '../../hmr/dialog'; 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, - buildNonAnimatedRestoreConfig, - captureDialogsForHmr, - CapturedHmrDialog, - consumePendingHmrDialogs, - HmrCandidateDialog, - peekPendingHmrDialogs, - suppressNativeCloseAnimation, -} from './dialog-hmr'; import { NativeDialogRef } from './dialog-ref'; import { NativeModalRef } from './native-modal-ref'; -function hmrDialogLog(message: string): void { - if (!isAngularHmrEnabled() || !NativeScriptDebug.isLogEnabled()) { - return; - } - NativeScriptDebug.hmrLog(`[dialog] ${message}`); -} - /** 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'); @@ -63,8 +43,6 @@ export class NativeDialog implements OnDestroy { private _openDialogsAtThisLevel: NativeDialogRef[] = []; private readonly _afterAllClosedAtThisLevel = new Subject(); private readonly _afterOpenedAtThisLevel = new Subject>(); - 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. @@ -99,7 +77,6 @@ export class NativeDialog implements OnDestroy { private _nativeModalType = NativeModalRef; private _dialogDataToken = NATIVE_DIALOG_DATA; private locationStrategy = inject(NSLocationStrategy); - private _hmrInitMarker = this._initHmrLifecycle(); /** * Opens a modal dialog containing the given component. * @param component Type of the component to load into the dialog. @@ -133,11 +110,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)); + if ((typeof ngDevMode === 'undefined' || ngDevMode) && !(componentOrTemplateRef instanceof TemplateRef)) { + trackHmrDialog(NativeDialog, dialogRef, componentOrTemplateRef, config); + } this.afterOpened.next(dialogRef); // Notify the dialog container that the content has been attached. @@ -167,190 +143,6 @@ export class NativeDialog implements OnDestroy { this._closeDialogs(this._openDialogsAtThisLevel); this._afterAllClosedAtThisLevel.complete(); this._afterOpenedAtThisLevel.complete(); - for (const sub of this._hmrSubscriptions) { - try { - sub.unsubscribe(); - } catch { - // ignore - } - } - this._hmrSubscriptions = []; - } - - private _restoreScheduledForThisInstance = false; - - private _initHmrLifecycle(): null { - if (this._parentDialog || !isAngularHmrEnabled()) { - return null; - } - - const dispose = preAngularDisposal$.subscribe((event) => { - if (event.moduleType !== 'main' || event.reason !== 'hotreload') { - return; - } - this._captureOpenDialogsForHmr(); - }); - - const bootstrap = postAngularBootstrap$.subscribe((event) => { - if (event.moduleType !== 'main' || event.reason !== 'hotreload') { - return; - } - this._maybeScheduleRestore(`postAngularBootstrap$ (reason=${event.reason})`); - }); - - this._hmrSubscriptions.push(dispose, bootstrap); - - const pendingNow = peekPendingHmrDialogs(); - if (pendingNow.length > 0) { - this._maybeScheduleRestore(`stash peek on ctor: ${pendingNow.length} pending dialog(s)`); - } - return null; - } - - private _maybeScheduleRestore(triggerDescription: string): void { - if (this._restoreScheduledForThisInstance) { - return; - } - this._restoreScheduledForThisInstance = true; - hmrDialogLog(`scheduling restore (trigger=${triggerDescription})`); - 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(), - }; - }); - - const captured = captureDialogsForHmr(candidates); - - if (captured.length > 0) { - 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`); - } - } - - private async _restorePendingDialogs(): Promise { - const pending = consumePendingHmrDialogs(); - if (pending.length === 0) { - return; - } - - hmrDialogLog(`restoring ${pending.length} dialog(s) after reboot [${pending.map((c) => c.componentName).join(', ')}]`); - - for (const captured of pending) { - this._restoreSingleDialog(captured); - } - } - - private _restoreSingleDialog(captured: CapturedHmrDialog): void { - const live = getFreshComponentClass>(captured.componentName); - const componentClass = live ?? captured.componentClass; - const usingFresh = !!live && live !== captured.componentClass; - this._scheduleRestoreOpenWhenReady(captured, componentClass, usingFresh); - } - - private static readonly _ROOT_VIEW_LOADED_TIMEOUT_MS = 1_000; - - private _scheduleRestoreOpenWhenReady( - captured: CapturedHmrDialog, - componentClass: ComponentType, - usingFresh: boolean, - ): void { - const rootView = Application.getRootView(); - - if (rootView && rootView.isLoaded) { - // Yield so the incoming root view can attach first. - setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); - return; - } - - if (!rootView) { - 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 { - // ignore - } - // viewWillAppear runs before the view is in a window. - setTimeout(() => this._performRestoreOpen(captured, componentClass, usingFresh), 0); - }; - - try { - rootView.once(View.loadedEvent, onLoaded); - } catch { - setTimeout(() => onLoaded(), 50); - } - - 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 { - if (usingFresh) { - hmrDialogLog(`restore ${captured.componentName} usingFreshClass=true`); - } - - 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}`); - NativeScriptDebug.hmrLogError(`HMR modal restore failed: ${message}`); - } } /** @@ -420,7 +212,6 @@ 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. @@ -454,16 +245,6 @@ function _applyConfigDefaults(config?: NativeDialogConfig, defaultOptions?: Nati return { ...defaultOptions, ...config }; } -if (isAngularHmrEnabled()) { - registerHmrEagerInstantiator((injector: Injector) => { - try { - injector.get(NativeDialog, null); - } catch { - // ignore - } - }); -} - export { /** * @deprecated Use `NativeDialog` instead. 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 deleted file mode 100644 index 9ccaadf2..00000000 --- a/packages/angular/src/lib/cdk/dialog/modal-host-props.spec.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { AddViewHost, installPvcModalHostPropPropagation, ModalHostView, propagateModalHostPropsToDescendants, PVC_ADD_VIEW_WRAPPED_MARKER } from './modal-host-props'; - -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; - } - } - } - - _addView(view: ModalHostView, _atIndex?: number): void { - this.children.push(view as FakeView); - } -} - -describe('modal-host-props', () => { - describe('propagateModalHostPropsToDescendants', () => { - it('mirrors `_dialogFragment` and `viewController` from the wrapper onto every native-like descendant', () => { - 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', () => { - const dialogFragment = { kind: 'DialogFragment' }; - const wrapper = new FakeView('wrapper'); - wrapper._dialogFragment = dialogFragment; - const sentinel = wrapper._dialogFragment; - - propagateModalHostPropsToDescendants(wrapper, wrapper); - - expect(wrapper._dialogFragment).toBe(sentinel); - }); - - it('no-ops when the modal has not been shown yet (wrapper has neither host prop)', () => { - 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)', () => { - 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)', () => { - 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)', () => { - 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', () => { - 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)', () => { - const dialogFragment = { kind: 'DialogFragment' }; - const wrapper = new FakeView('wrapper'); - wrapper._dialogFragment = dialogFragment; - - 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', () => { - 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', () => { - 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)', () => { - 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', () => { - const wrapper = new FakeView('wrapper'); - wrapper._dialogFragment = { kind: 'A' }; - const pvc = new FakeView('pvc'); - installPvcModalHostPropPropagation(pvc, wrapper); - - 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 deleted file mode 100644 index 2dd5bf1f..00000000 --- a/packages/angular/src/lib/cdk/dialog/modal-host-props.ts +++ /dev/null @@ -1,64 +0,0 @@ -export interface ModalHostView { - _dialogFragment?: unknown; - viewController?: unknown; - eachChildView?: (callback: (child: ModalHostView) => boolean) => void; -} - -export interface AddViewHost extends ModalHostView { - _addView?: (view: ModalHostView, atIndex?: number) => void; -} - -export const PVC_ADD_VIEW_WRAPPED_MARKER = '__ng_modal_propagate_addview__'; - -/** - * Copy wrapper modal-host props onto every descendant of root. - */ -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); -} - -/** - * Wrap host._addView so later children receive wrapper modal-host props. - */ -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) => { - // loaded fires inside _addView; props must exist first. - 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 759a0c57..de81a889 100644 --- a/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts +++ b/packages/angular/src/lib/cdk/dialog/native-modal-ref.ts @@ -2,6 +2,8 @@ import { ApplicationRef, ComponentRef, createComponent, EmbeddedViewRef, Injecto import { Application, ContentView, Frame, View } from '@nativescript/core'; import { Subject } from 'rxjs'; import { AppHostAsyncView, AppHostView } from '../../app-host-view'; +import { isHmrActive } from '../../hmr/hmr'; +import { shareModalHostProps } from '../../hmr/modal-host'; import { NSLocationStrategy } from '../../legacy/router/ns-location-strategy'; import { didModalOpen, once } from '../../utils/general'; import { NgViewRef } from '../../view-refs'; @@ -9,7 +11,6 @@ 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; @@ -20,15 +21,7 @@ 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. - */ + /** View presented with showModal when it is not `modalViewRef.firstNativeLikeView`. */ modalView?: View; private _closeCallback: () => void; @@ -62,9 +55,7 @@ export class NativeModalRef { this._closeCallback = once(() => { this.stateChanged.next({ state: 'closing' }); if (!this._isDismissed) { - // Prefer the presented wrapper; HMR may replace the first root. - const closeTarget = this.modalView ?? this.modalViewRef.firstNativeLikeView; - closeTarget?.closeModal(); + (this.modalView ?? this.modalViewRef.firstNativeLikeView)?.closeModal(); } }); } @@ -106,29 +97,32 @@ export class NativeModalRef { attachComponentPortal(portal: ComponentPortal): ComponentRef { this.startModalNavigation(); - // Present a stable wrapper so HMR can replace the template root. 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; } - targetView['__ng_modal_id__'] = this._id; - if (this.modalViewRef.firstNativeLikeView) { - this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; - } - - this._showModal(targetView); + this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; - 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); + if ((typeof ngDevMode === 'undefined' || ngDevMode) && isHmrActive()) { + // Present the stable outlet so in-place template HMR can replace the component's root view. + const root = this.modalViewRef.firstNativeLikeView; + targetView.width = root.width; + targetView.height = root.height; + targetView['__ng_modal_id__'] = this._id; + this.modalView = targetView; + this._showModal(targetView); + shareModalHostProps(targetView, componentRef.location.nativeElement); + return componentRef; } + // if we don't detach the view from its parent, ios gets mad + this.modalViewRef.detachNativeLikeView(); + + this._showModal(this.modalViewRef.firstNativeLikeView); return componentRef; } @@ -165,7 +159,6 @@ export class NativeModalRef { // Core tears the view down right after this callback returns, so the listener has to be // attached synchronously, before any await. - // Prefer the presented wrapper; HMR may replace the first root. const modalView = this.modalView ?? this.modalViewRef?.firstNativeLikeView; const whenUnloaded = modalView?.isLoaded ? new Promise((resolve) => modalView.once(View.unloadedEvent, () => resolve())) : Promise.resolve(); @@ -193,7 +186,6 @@ 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/element-registry/common-views.ts b/packages/angular/src/lib/element-registry/common-views.ts index 42516533..f4436ec9 100644 --- a/packages/angular/src/lib/element-registry/common-views.ts +++ b/packages/angular/src/lib/element-registry/common-views.ts @@ -1,83 +1,49 @@ -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() { - 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); + 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); + } } diff --git a/packages/angular/src/lib/hmr-cache-store.ts b/packages/angular/src/lib/hmr-cache-store.ts deleted file mode 100644 index 7069af33..00000000 --- a/packages/angular/src/lib/hmr-cache-store.ts +++ /dev/null @@ -1,130 +0,0 @@ -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; - clear(): void; - size(): number; -} - -export class HmrCacheStore { - private readonly _map: Map; - private readonly _maxEntries: number; - private readonly _log: (message: string) => void; - - constructor(initialEntries: Iterable<[string, unknown]> = [], options: { maxEntries?: number; log?: (message: string) => void } = {}) { - this._map = new Map(initialEntries); - const requested = options.maxEntries; - this._maxEntries = typeof requested === 'number' && requested > 0 ? Math.floor(requested) : 0; - this._log = options.log ?? (() => undefined); - this._enforceMaxEntries(); - } - - get(key: string): T | undefined { - if (!this._map.has(key)) { - return undefined; - } - 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)) { - 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); - } - - invalidate(key?: string): void { - if (key === undefined || key === null) { - this.clear(); - return; - } - this.delete(key); - } - - clear(): void { - this._map.clear(); - } - - size(): number { - return this._map.size; - } - - keys(): string[] { - return Array.from(this._map.keys()); - } - - scope(prefix: string): HmrCacheScope { - if (!prefix) { - throw new Error('[HmrCache] 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; - }, - }; - } - - 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(`[HmrCache] evicted oldest key="${oldestKey}" (size now ${this._map.size}/${this._maxEntries})`); - } - } -} diff --git a/packages/angular/src/lib/hmr-cache.spec.ts b/packages/angular/src/lib/hmr-cache.spec.ts deleted file mode 100644 index 9fc9505c..00000000 --- a/packages/angular/src/lib/hmr-cache.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { HmrCacheStore } from './hmr-cache-store'; - -describe('HmrCacheStore', () => { - it('get/set/has/delete and LRU-evicts oldest entries', () => { - const store = new HmrCacheStore([], { maxEntries: 2 }); - store.set('a', 1); - store.set('b', 2); - expect(store.get('a')).toBe(1); - store.set('c', 3); - expect(store.has('b')).toBe(false); - expect(store.keys()).toEqual(['a', 'c']); - store.delete('a'); - expect(store.size()).toBe(1); - store.invalidate(); - expect(store.size()).toBe(0); - }); - - it('scopes keys and can clear only that prefix', () => { - const store = new HmrCacheStore(); - const page = store.scope('page'); - page.set('items', [1]); - store.set('other', true); - expect(page.get('items')).toEqual([1]); - expect(store.get('page:items')).toEqual([1]); - expect(page.size()).toBe(1); - page.clear(); - expect(store.has('other')).toBe(true); - expect(page.has('items')).toBe(false); - expect(() => store.scope('')).toThrow(); - }); - - it('trims a seed that already exceeds maxEntries', () => { - const store = new HmrCacheStore( - [ - ['one', 1], - ['two', 2], - ['three', 3], - ], - { maxEntries: 2 }, - ); - expect(store.keys()).toEqual(['two', 'three']); - }); -}); diff --git a/packages/angular/src/lib/hmr-cache.ts b/packages/angular/src/lib/hmr-cache.ts deleted file mode 100644 index 96c97fb7..00000000 --- a/packages/angular/src/lib/hmr-cache.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable } from '@angular/core'; -import { isAngularHmrEnabled } from './hmr'; -import { HmrCacheScope, HmrCacheStore } from './hmr-cache-store'; -import { NativeScriptDebug } from './trace'; - -export type { HmrCacheScope } from './hmr-cache-store'; - -const DEFAULT_MAX_ENTRIES = 256; -const STORAGE_KEY = 'ns-hmr-cache'; -const INVALIDATE_EVENT = 'ns:cache-invalidate'; - -interface NsHotContext { - readonly data: Record; - dispose: (cb: (data: Record) => void) => void; - on?: (event: string, cb: (payload: unknown) => void) => void; -} - -function readImportMetaHot(): NsHotContext | undefined { - try { - return (import.meta as unknown as { hot?: NsHotContext })['hot']; - } catch { - return undefined; - } -} - -export interface HmrCacheOptions { - maxEntries?: number; -} - -function createSharedStore(options: HmrCacheOptions & { log?: (message: string) => void } = {}): HmrCacheStore { - const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; - const log = options.log ?? (() => undefined); - const hot = readImportMetaHot(); - const previousSnapshot = ((hot?.data as Record | undefined)?.[STORAGE_KEY] ?? {}) as Record; - const store = new HmrCacheStore(Object.entries(previousSnapshot), { maxEntries, log }); - - if (hot) { - hot.dispose((data) => { - (data as Record)[STORAGE_KEY] = store.toObject(); - }); - if (typeof hot.on === 'function') { - hot.on(INVALIDATE_EVENT, (payload: { key?: string } | undefined) => { - store.invalidate(payload?.key); - }); - } - } - - return store; -} - -let sharedStore: HmrCacheStore | null = null; -let pendingOptions: HmrCacheOptions | null = null; - -function getOrCreateSharedStore(): HmrCacheStore { - if (sharedStore !== null) { - return sharedStore; - } - sharedStore = createSharedStore({ - log: (msg) => { - try { - NativeScriptDebug.bootstrapLog(msg); - } catch { - // ignore - } - }, - ...pendingOptions, - }); - pendingOptions = null; - return sharedStore; -} - -/** - * Set cache options before the first HmrCacheService inject. - * @returns true if stored, false if the store already exists. - */ -export function configureHmrCache(options: HmrCacheOptions): boolean { - if (sharedStore !== null) { - return false; - } - pendingOptions = options; - return true; -} - -/** - * Per-app key/value cache that survives `__reboot_ng_modules__`. - */ -@Injectable({ providedIn: 'root' }) -export class HmrCacheService { - private readonly _store = getOrCreateSharedStore(); - readonly isHmr: boolean = isAngularHmrEnabled() && !!readImportMetaHot(); - - 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); - } - - invalidate(key?: string): void { - this._store.invalidate(key); - } - - clear(): void { - this._store.clear(); - } - - size(): number { - return this._store.size(); - } - - keys(): string[] { - return this._store.keys(); - } - - scope(scopeName: string): HmrCacheScope { - return this._store.scope(scopeName); - } -} diff --git a/packages/angular/src/lib/hmr.spec.ts b/packages/angular/src/lib/hmr.spec.ts deleted file mode 100644 index e4b68937..00000000 --- a/packages/angular/src/lib/hmr.spec.ts +++ /dev/null @@ -1,248 +0,0 @@ -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, - _registerComponentForHmr, - clearAngularHmrClassRegistry, - clearHmrEagerInstantiators, - getAngularCoreForHmrReset, - getFreshComponentClass, - getRegisteredHmrEagerInstantiators, - HmrEagerInstantiator, - installAngularHmrComponentRegistrar, - isAngularDevMode, - isAngularHmrEnabled, - isNativeScriptViteHmrActive, - isWebpackHmrActive, - registerHmrEagerInstantiator, - rememberAngularCoreForHmr, - resetAngularHmrCompiledComponents, - runHmrEagerInstantiators, - setAngularCoreForHmr, -} from './hmr'; - -interface MutableGlobal { - ngDevMode?: boolean; - __NS_DEV_PLACEHOLDER_ROOT_EARLY__?: unknown; - __NS_HMR_BOOT_COMPLETE__?: unknown; - __webpack_require__?: unknown; -} - -const HOOK_KEY = '__NS_HMR_REGISTER_COMPONENT__'; - -describe('hmr environment', () => { - const g = globalThis as unknown as MutableGlobal; - 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; - } - }); - - it('treats undefined ngDevMode as dev', () => { - delete g.ngDevMode; - expect(isAngularDevMode()).toBe(true); - }); - - it('returns false in production even when HMR flags are set', () => { - 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('detects the Vite early placeholder and the post-boot complete flag', () => { - delete g.ngDevMode; - delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; - delete g.__NS_HMR_BOOT_COMPLETE__; - delete g.__webpack_require__; - expect(isNativeScriptViteHmrActive()).toBe(false); - - g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ = true; - expect(isNativeScriptViteHmrActive()).toBe(true); - expect(isAngularHmrEnabled()).toBe(true); - - delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; - g.__NS_HMR_BOOT_COMPLETE__ = true; - expect(isNativeScriptViteHmrActive()).toBe(true); - expect(isAngularHmrEnabled()).toBe(true); - }); - - it('detects webpack HMR via __webpack_require__', () => { - delete g.ngDevMode; - delete g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__; - delete g.__NS_HMR_BOOT_COMPLETE__; - delete g.__webpack_require__; - expect(isWebpackHmrActive()).toBe(false); - expect(isAngularHmrEnabled()).toBe(false); - - g.__webpack_require__ = () => undefined; - expect(isWebpackHmrActive()).toBe(true); - expect(isAngularHmrEnabled()).toBe(true); - - g.__webpack_require__ = 'not-a-function' as unknown as object; - expect(isWebpackHmrActive()).toBe(false); - }); -}); - -describe('hmr class registry', () => { - afterEach(() => { - clearAngularHmrClassRegistry(); - }); - - it('installs the registrar unconditionally and is idempotent', () => { - installAngularHmrComponentRegistrar(); - const first = (globalThis as Record)[HOOK_KEY]; - expect(typeof first).toBe('function'); - installAngularHmrComponentRegistrar(); - expect((globalThis as Record)[HOOK_KEY]).toBe(first); - }); - - it('keeps the latest class identity across reboot registrations', () => { - installAngularHmrComponentRegistrar(); - const hook = (globalThis as Record)[HOOK_KEY] as (name: string, cls: unknown) => void; - class FooV1 {} - class FooV2 {} - hook('FooComponent', FooV1); - expect(getFreshComponentClass('FooComponent')).toBe(FooV1); - hook('FooComponent', FooV2); - expect(getFreshComponentClass('FooComponent')).toBe(FooV2); - }); - - it('skips invalid registrations and empty lookups', () => { - class Foo {} - _registerComponentForHmr('', Foo); - _registerComponentForHmr('Foo', null); - expect(getFreshComponentClass('')).toBeUndefined(); - expect(getFreshComponentClass('Foo')).toBeUndefined(); - expect(getFreshComponentClass('NeverRegistered')).toBeUndefined(); - }); - - it('survives a simulated reboot via the same global hook', () => { - installAngularHmrComponentRegistrar(); - const hook = (globalThis as Record)[HOOK_KEY] as (name: string, cls: unknown) => void; - class CycleOne {} - class CycleTwo {} - _hmrDiagBumpCycle(); - hook('ResourceModalComponent', CycleOne); - expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleOne); - _hmrDiagBumpCycle(); - hook('ResourceModalComponent', CycleTwo); - expect(getFreshComponentClass('ResourceModalComponent')).toBe(CycleTwo); - expect(_hmrDiagSnapshot().registerCalls).toBe(2); - }); -}); - -describe('hmr eager services', () => { - beforeEach(() => { - clearHmrEagerInstantiators(); - }); - - it('registers callbacks once and runs them with the injector', () => { - const calls: unknown[] = []; - const fn: HmrEagerInstantiator = (injector) => calls.push(injector); - expect(registerHmrEagerInstantiator(fn)).toBe(true); - expect(registerHmrEagerInstantiator(fn)).toBe(false); - expect(registerHmrEagerInstantiator('nope' as unknown as HmrEagerInstantiator)).toBe(false); - const injector = { id: 'fake' } as unknown as Parameters[0]; - runHmrEagerInstantiators(injector); - expect(calls).toEqual([injector]); - expect(getRegisteredHmrEagerInstantiators()).toEqual([fn]); - }); - - it('continues after a callback throws and ignores reporter failures', () => { - 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[0] as Error).message).toBe('boom'); - - clearHmrEagerInstantiators(); - 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).toContain('second ran'); - }); - - it('is a no-op without an injector', () => { - expect(() => runHmrEagerInstantiators(undefined)).not.toThrow(); - expect(() => runHmrEagerInstantiators(null)).not.toThrow(); - }); -}); - -describe('hmr compiled component reset', () => { - it('calls Angular reset when available and swallows failures', () => { - const core = { ɵresetCompiledComponents: jest.fn() }; - expect(resetAngularHmrCompiledComponents(core)).toBe(true); - expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1); - expect(resetAngularHmrCompiledComponents({})).toBe(false); - const exploding = { - ɵresetCompiledComponents: jest.fn(() => { - throw new Error('boom'); - }), - }; - expect(resetAngularHmrCompiledComponents(exploding)).toBe(false); - }); - - it('remembers the first Angular core and allows an explicit replace', () => { - const originalCore = { ɵresetCompiledComponents: jest.fn() }; - const replacementCore = { ɵresetCompiledComponents: jest.fn() }; - const globalObj: any = {}; - expect(rememberAngularCoreForHmr(originalCore, globalObj)).toBe(originalCore); - expect(rememberAngularCoreForHmr(replacementCore, globalObj)).toBe(originalCore); - expect(getAngularCoreForHmrReset(replacementCore, globalObj)).toBe(originalCore); - expect(setAngularCoreForHmr(replacementCore, globalObj)).toBe(replacementCore); - }); -}); diff --git a/packages/angular/src/lib/hmr.ts b/packages/angular/src/lib/hmr.ts deleted file mode 100644 index 394faec1..00000000 --- a/packages/angular/src/lib/hmr.ts +++ /dev/null @@ -1,233 +0,0 @@ -import type { Injector } from '@angular/core'; -import { NativeScriptDebug } from './trace'; - -declare const ngDevMode: boolean | undefined; - -type AngularCoreWithCompiledComponentReset = { - ɵresetCompiledComponents?: () => void; -}; - -type AngularCoreHolder = { - __NS_ANGULAR_CORE__?: AngularCoreWithCompiledComponentReset | null; -}; - -export function isAngularDevMode(): boolean { - if (typeof ngDevMode === 'undefined') { - return true; - } - return ngDevMode !== false; -} - -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__); -} - -export function isWebpackHmrActive(): boolean { - return typeof (globalThis as { __webpack_require__?: unknown }).__webpack_require__ === 'function'; -} - -/** - * True in Angular dev when Vite or webpack HMR is active. - */ -export function isAngularHmrEnabled(): boolean { - if (typeof ngDevMode !== 'undefined' && ngDevMode === false) { - return false; - } - return isNativeScriptViteHmrActive() || isWebpackHmrActive(); -} - -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, -): 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 { - const resetCompiledComponents = core?.ɵresetCompiledComponents; - if (typeof resetCompiledComponents !== 'function') { - return false; - } - try { - resetCompiledComponents.call(core); - return true; - } catch { - return false; - } -} - -const REGISTRY_KEY = '__NS_ANGULAR_HMR_CLASS_REGISTRY__'; -const REGISTRAR_HOOK = '__NS_HMR_REGISTER_COMPONENT__'; -const REGISTRAR_INSTALLED_FLAG = '__NS_ANGULAR_HMR_REGISTRAR_INSTALLED__'; -const DIAG_KEY = '__NS_HMR_DIAG__'; - -type HmrClassRegistry = Map; - -interface DiagSlot { - [DIAG_KEY]?: { - cycle: number; - registerCalls: number; - }; -} - -interface GlobalRegistrySlot { - [REGISTRY_KEY]?: HmrClassRegistry; - [REGISTRAR_INSTALLED_FLAG]?: boolean; - [REGISTRAR_HOOK]?: (name: string, cls: unknown, url?: string) => void; -} - -function getDiag() { - const slot = globalThis as unknown as DiagSlot; - if (!slot[DIAG_KEY]) { - slot[DIAG_KEY] = { cycle: 0, registerCalls: 0 }; - } - return slot[DIAG_KEY]!; -} - -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; -} - -export function _registerComponentForHmr(name: string, cls: unknown, _url = ''): void { - if (!name || typeof name !== 'string') { - return; - } - if (cls === undefined || cls === null) { - return; - } - getRegistry().set(name, cls); - getDiag().registerCalls += 1; -} - -/** - * Install `__NS_HMR_REGISTER_COMPONENT__` for Vite class identity updates. - */ -export function installAngularHmrComponentRegistrar(): void { - const slot = globalThis as unknown as GlobalRegistrySlot; - if (slot[REGISTRAR_INSTALLED_FLAG]) { - return; - } - slot[REGISTRAR_HOOK] = (name: string, cls: unknown, url?: string) => { - try { - _registerComponentForHmr(name, cls, typeof url === 'string' ? url : ''); - } catch (err) { - if (isAngularHmrEnabled() && NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.hmrLog(`[class-registry] registrar threw for ${name}: ${(err as Error)?.message ?? err}`); - } - } - }; - slot[REGISTRAR_INSTALLED_FLAG] = true; -} - -export function getFreshComponentClass(name: string): T | undefined { - if (!name) { - return undefined; - } - const registry = (globalThis as unknown as GlobalRegistrySlot)[REGISTRY_KEY]; - return registry?.get(name) as T | undefined; -} - -export function _hmrDiagBumpCycle(): number { - const diag = getDiag(); - diag.cycle += 1; - installAngularHmrComponentRegistrar(); - return diag.cycle; -} - -export function _hmrDiagSnapshot(): { cycle: number; registerCalls: number } { - const diag = getDiag(); - return { cycle: diag.cycle, registerCalls: diag.registerCalls }; -} - -export function clearAngularHmrClassRegistry(): void { - const slot = globalThis as unknown as GlobalRegistrySlot; - slot[REGISTRY_KEY] = undefined; - slot[REGISTRAR_INSTALLED_FLAG] = undefined; - slot[REGISTRAR_HOOK] = undefined; - (globalThis as unknown as DiagSlot)[DIAG_KEY] = undefined; -} - -export type HmrEagerInstantiator = (injector: Injector) => void; - -const EAGER_REGISTRY_KEY = '__NS_HMR_EAGER_SERVICES__'; - -interface HmrEagerGlobals { - [EAGER_REGISTRY_KEY]?: HmrEagerInstantiator[]; -} - -export function getRegisteredHmrEagerInstantiators(): HmrEagerInstantiator[] { - const store = globalThis as unknown as HmrEagerGlobals; - const list = store[EAGER_REGISTRY_KEY]; - if (!Array.isArray(list)) { - const fresh: HmrEagerInstantiator[] = []; - store[EAGER_REGISTRY_KEY] = fresh; - return fresh; - } - return list; -} - -export function registerHmrEagerInstantiator(fn: HmrEagerInstantiator): boolean { - if (typeof fn !== 'function') { - return false; - } - const list = getRegisteredHmrEagerInstantiators(); - if (list.includes(fn)) { - return false; - } - list.push(fn); - return true; -} - -export function clearHmrEagerInstantiators(): void { - getRegisteredHmrEagerInstantiators().length = 0; -} - -export function runHmrEagerInstantiators(injector: Injector | null | undefined, onError?: (err: unknown) => void): void { - if (!injector) { - return; - } - const list = getRegisteredHmrEagerInstantiators(); - for (let i = 0; i < list.length; i++) { - try { - list[i](injector); - } catch (err) { - if (onError) { - try { - onError(err); - } catch { - // ignore - } - } - } - } -} diff --git a/packages/angular/src/lib/hmr/cache.spec.ts b/packages/angular/src/lib/hmr/cache.spec.ts new file mode 100644 index 00000000..2ea1fb60 --- /dev/null +++ b/packages/angular/src/lib/hmr/cache.spec.ts @@ -0,0 +1,46 @@ +jest.mock('@angular/core', () => ({ + Injectable: () => (target: unknown) => target, + ɵresetCompiledComponents: jest.fn(), +})); +jest.mock('../trace', () => ({ NativeScriptDebug: { isLogEnabled: () => false } })); + +let cacheModule: typeof import('./cache'); + +beforeEach(() => { + jest.isolateModules(() => { + cacheModule = require('./cache'); + }); + delete (globalThis as Record).__NS_NG_HMR__; +}); + +describe('HmrCacheService', () => { + it('evicts the least recently used entries beyond maxEntries', () => { + expect(cacheModule.configureHmrCache({ maxEntries: 2 })).toBe(true); + const cache = new cacheModule.HmrCacheService(); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.get('a')).toBe(1); + cache.set('c', 3); + + expect(cache.keys()).toEqual(['a', 'c']); + expect(cacheModule.configureHmrCache({ maxEntries: 5 })).toBe(false); + }); + + it('shares entries between instances', () => { + new cacheModule.HmrCacheService().set('k', 'v'); + expect(new cacheModule.HmrCacheService().get('k')).toBe('v'); + }); + + it('scopes keys by prefix', () => { + const cache = new cacheModule.HmrCacheService(); + const page = cache.scope('page'); + page.set('items', [1]); + cache.set('other', true); + + expect(cache.get('page:items')).toEqual([1]); + expect(page.size()).toBe(1); + page.clear(); + expect(cache.keys()).toEqual(['other']); + expect(() => cache.scope('')).toThrow(); + }); +}); diff --git a/packages/angular/src/lib/hmr/cache.ts b/packages/angular/src/lib/hmr/cache.ts new file mode 100644 index 00000000..8409345a --- /dev/null +++ b/packages/angular/src/lib/hmr/cache.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@angular/core'; +import { hmrState, isHmrActive } from './hmr'; + +export interface HmrCacheOptions { + /** Least recently used entries beyond this count are evicted. Defaults to 256; 0 disables the limit. */ + maxEntries?: number; +} + +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; + clear(): void; + size(): number; +} + +let maxEntries = 256; +let entries: Map | undefined; + +function sharedEntries(): Map { + if (!entries) { + // In dev the map lives in HMR state so it survives reboots even if this module is re-evaluated. + entries = + typeof ngDevMode === 'undefined' || ngDevMode + ? ((hmrState().cache ??= new Map()) as Map) + : new Map(); + } + return entries; +} + +/** + * Sets cache options. Only applies before the cache is first used. + * @returns false when the cache is already in use. + */ +export function configureHmrCache(options: HmrCacheOptions): boolean { + if (entries) { + return false; + } + maxEntries = Math.max(0, Math.floor(options.maxEntries ?? maxEntries)); + return true; +} + +/** + * App-wide key/value cache that survives HMR reboots, so screens can keep expensive state + * (fetched data, scroll positions) while editing. In release builds it is a plain in-memory LRU cache. + */ +@Injectable({ providedIn: 'root' }) +export class HmrCacheService { + /** True when running inside a live HMR session. */ + readonly isHmr: boolean = (typeof ngDevMode === 'undefined' || !!ngDevMode) && isHmrActive(); + private readonly entries = sharedEntries(); + + get(key: string): T | undefined { + if (!this.entries.has(key)) { + return undefined; + } + const value = this.entries.get(key); + // Re-insert to mark as most recently used. + this.entries.delete(key); + this.entries.set(key, value); + return value as T; + } + + set(key: string, value: T): void { + this.entries.delete(key); + this.entries.set(key, value); + if (maxEntries > 0) { + for (const oldest of this.entries.keys()) { + if (this.entries.size <= maxEntries) { + break; + } + this.entries.delete(oldest); + } + } + } + + has(key: string): boolean { + return this.entries.has(key); + } + + delete(key: string): void { + this.entries.delete(key); + } + + clear(): void { + this.entries.clear(); + } + + size(): number { + return this.entries.size; + } + + keys(): string[] { + return [...this.entries.keys()]; + } + + /** View of the cache whose keys are prefixed with `name:`. */ + scope(name: string): HmrCacheScope { + if (!name) { + throw new Error('[HmrCache] scope() requires a non-empty name'); + } + const prefix = `${name}:`; + const scopedKeys = () => this.keys().filter((k) => k.startsWith(prefix)); + return { + prefix, + get: (key) => this.get(prefix + key), + set: (key, value) => this.set(prefix + key, value), + has: (key) => this.has(prefix + key), + delete: (key) => this.delete(prefix + key), + clear: () => scopedKeys().forEach((k) => this.delete(k)), + size: () => scopedKeys().length, + }; + } +} diff --git a/packages/angular/src/lib/hmr/dialog.spec.ts b/packages/angular/src/lib/hmr/dialog.spec.ts new file mode 100644 index 00000000..6c7048c2 --- /dev/null +++ b/packages/angular/src/lib/hmr/dialog.spec.ts @@ -0,0 +1,85 @@ +import { Subject } from 'rxjs'; + +const rootView = { isLoaded: true }; +class MockApplicationRef {} +jest.mock('@angular/core', () => ({ ApplicationRef: MockApplicationRef, ɵresetCompiledComponents: jest.fn() })); +jest.mock('@nativescript/core', () => ({ Application: { getRootView: () => rootView } })); +jest.mock('../trace', () => ({ + NativeScriptDebug: { isLogEnabled: () => false, hmrLog: jest.fn(), hmrLogError: jest.fn() }, +})); + +import { trackHmrDialog } from './dialog'; +import { hmrState, runHmrHooks } from './hmr'; + +class DialogComponent {} +class NativeDialogToken {} + +function fakeRef() { + const closed = new Subject(); + const animated = [true]; + const ref = { + afterClosed: () => closed, + _nativeModalRef: { parentView: { _modalAnimatedOptions: animated } }, + } as any; + return { ref, closed, animated }; +} + +function fakeInjector() { + const dialog = { open: jest.fn() }; + const appRef = { whenStable: () => Promise.resolve() }; + const tokens = new Map([ + [NativeDialogToken, dialog], + [MockApplicationRef, appRef], + ]); + return { dialog, injector: { get: (token: unknown) => tokens.get(token) ?? null } as any }; +} + +beforeEach(() => { + jest.useFakeTimers(); + delete (globalThis as Record).__NS_NG_HMR__; +}); + +afterEach(() => jest.useRealTimers()); + +describe('dialog restore', () => { + it('reopens preserved dialogs once the app is stable, without animation, using the fresh class', async () => { + const { ref, animated } = fakeRef(); + trackHmrDialog(NativeDialogToken as any, ref, DialogComponent, { + preserveOnHmr: true, + data: { id: 1 }, + nativeOptions: { fullscreen: true }, + } as any); + runHmrHooks('beforeDispose', {} as any); + expect(animated).toEqual([false]); + + class FreshDialogComponent {} + hmrState().components.set('DialogComponent', FreshDialogComponent); + const { dialog, injector } = fakeInjector(); + runHmrHooks('afterBootstrap', injector); + await jest.runAllTimersAsync(); + + expect(dialog.open).toHaveBeenCalledTimes(1); + const [component, config] = dialog.open.mock.calls[0]; + expect(component).toBe(FreshDialogComponent); + expect(config).toMatchObject({ + data: { id: 1 }, + renderIn: 'root', + nativeOptions: { fullscreen: true, animated: false }, + }); + }); + + it('ignores dialogs that did not opt in or were already closed', async () => { + const optedOut = fakeRef(); + trackHmrDialog(NativeDialogToken as any, optedOut.ref, DialogComponent, { preserveOnHmr: false } as any); + const closed = fakeRef(); + trackHmrDialog(NativeDialogToken as any, closed.ref, DialogComponent, { preserveOnHmr: true } as any); + closed.closed.next(); + + runHmrHooks('beforeDispose', {} as any); + const { dialog, injector } = fakeInjector(); + runHmrHooks('afterBootstrap', injector); + await jest.runAllTimersAsync(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/angular/src/lib/hmr/dialog.ts b/packages/angular/src/lib/hmr/dialog.ts new file mode 100644 index 00000000..6a804e38 --- /dev/null +++ b/packages/angular/src/lib/hmr/dialog.ts @@ -0,0 +1,109 @@ +import { ApplicationRef, type Injector, type Type } from '@angular/core'; +import { Application } from '@nativescript/core'; +import type { NativeDialogConfig } from '../cdk/dialog/dialog-config'; +import type { NativeDialogRef } from '../cdk/dialog/dialog-ref'; +import { getHmrComponentClass, hmrLog, hmrState, registerHmrHooks } from './hmr'; + +interface DialogOpener { + open(component: Type, config: NativeDialogConfig): NativeDialogRef; +} + +interface PreservedDialog { + component: Type; + config: NativeDialogConfig; +} + +const ROOT_VIEW_WAIT_MS = 1000; +const STABLE_WAIT_MS = 2000; +const open = new Map, PreservedDialog>(); + +/** + * Remembers a component dialog opened with `preserveOnHmr` so the next reboot can reopen it. + * @param dialogToken the NativeDialog class, passed in to avoid an import cycle with the service. + */ +export function trackHmrDialog( + dialogToken: Type, + ref: NativeDialogRef, + component: Type, + config: NativeDialogConfig, +): void { + if (!config.preserveOnHmr) { + return; + } + open.set(ref, { component, config }); + ref.afterClosed().subscribe(() => open.delete(ref)); + registerHmrHooks('dialog', { + beforeDispose: captureDialogs, + afterBootstrap: (injector) => restoreDialogs(injector, dialogToken), + }); +} + +function captureDialogs(): void { + hmrState().dialogs = [...open.entries()].map(([ref, dialog]) => { + suppressCloseAnimation(ref); + return dialog; + }); + open.clear(); +} + +function restoreDialogs(injector: Injector, dialogToken: Type): void { + const state = hmrState(); + const pending = state.dialogs as PreservedDialog[]; + if (!pending.length) { + return; + } + state.dialogs = []; + // Waiting for stability lets the initial navigation lazy-load (and so re-evaluate) edited dialog components. + // Capped because apps with recurring timers under zone.js never become stable. + const settled = Promise.race([ + injector.get(ApplicationRef).whenStable(), + new Promise((resolve) => setTimeout(resolve, STABLE_WAIT_MS)), + ]); + void settled.then(() => + whenRootViewLoaded(() => { + const dialog = injector.get(dialogToken); + for (const { component, config } of pending) { + const fresh = getHmrComponentClass>(component.name) ?? component; + try { + // The original view container and injector were destroyed with the previous app. + dialog.open(fresh, { + ...config, + viewContainerRef: undefined, + injector: undefined, + renderIn: 'root', + nativeOptions: { ...config.nativeOptions, animated: false }, + }); + hmrLog(`reopened dialog ${component.name}`); + } catch (err) { + hmrLog(`could not reopen dialog ${component.name}: ${(err as Error)?.message ?? err}`); + } + } + }), + ); +} + +// The disposing app closes its modals; skip the close animation so the reopen looks seamless. +function suppressCloseAnimation(ref: NativeDialogRef): void { + const parentView = (ref as unknown as { _nativeModalRef?: { parentView?: { _modalAnimatedOptions?: boolean[] } } }) + ._nativeModalRef?.parentView; + const animated = parentView?._modalAnimatedOptions; + if (animated?.length) { + animated[animated.length - 1] = false; + } +} + +function whenRootViewLoaded(callback: () => void): void { + const started = Date.now(); + const check = () => { + const root = Application.getRootView(); + if (root?.isLoaded) { + // Let the new root finish attaching before presenting on top of it. + setTimeout(callback); + } else if (Date.now() - started < ROOT_VIEW_WAIT_MS) { + setTimeout(check, 16); + } else { + hmrLog('dialog restore skipped: root view never loaded'); + } + }; + check(); +} diff --git a/packages/angular/src/lib/hmr/hmr.spec.ts b/packages/angular/src/lib/hmr/hmr.spec.ts new file mode 100644 index 00000000..6ae69a06 --- /dev/null +++ b/packages/angular/src/lib/hmr/hmr.spec.ts @@ -0,0 +1,90 @@ +const resetCompiledComponents = jest.fn(); +jest.mock('@angular/core', () => ({ ɵresetCompiledComponents: () => resetCompiledComponents() })); +jest.mock('../trace', () => ({ + NativeScriptDebug: { isLogEnabled: () => false, hmrLog: jest.fn(), hmrLogError: jest.fn() }, +})); + +import { + getHmrComponentClass, + handOffHmrAppOptions, + hmrState, + installViteHmrGlobals, + registerHmrHooks, + runHmrHooks, +} from './hmr'; + +const g = globalThis as Record; +const injector = {} as any; + +afterEach(() => { + for (const key of [ + '__NS_NG_HMR__', + '__NS_ANGULAR_HMR_REGISTER_ONLY__', + '__NS_UPDATE_ANGULAR_APP_OPTIONS__', + '__reset_ng_compiled_components__', + '__NS_HMR_REGISTER_COMPONENT__', + ]) { + delete g[key]; + } +}); + +describe('hmr hooks', () => { + it('runs every registered hook for a phase and isolates failures', () => { + const calls: string[] = []; + registerHmrHooks('a', { + beforeDispose: () => { + throw new Error('boom'); + }, + }); + registerHmrHooks('b', { beforeDispose: () => calls.push('b'), afterBootstrap: () => calls.push('b:after') }); + + runHmrHooks('beforeDispose', injector); + + expect(calls).toEqual(['b']); + }); + + it('replaces hooks registered under the same name', () => { + const first = jest.fn(); + const second = jest.fn(); + registerHmrHooks('x', { afterBootstrap: first }); + registerHmrHooks('x', { afterBootstrap: second }); + + runHmrHooks('afterBootstrap', injector); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith(injector); + }); + + it('skips hooks without an injector', () => { + const hook = jest.fn(); + registerHmrHooks('x', { beforeDispose: hook }); + runHmrHooks('beforeDispose', null); + expect(hook).not.toHaveBeenCalled(); + }); +}); + +describe('vite globals', () => { + it('hands options to the running app only while Vite re-imports the entry', () => { + const update = jest.fn(); + installViteHmrGlobals(update); + + expect(handOffHmrAppOptions({ a: 1 })).toBe(false); + g.__NS_ANGULAR_HMR_REGISTER_ONLY__ = true; + expect(handOffHmrAppOptions({ a: 2 })).toBe(true); + expect(update).toHaveBeenCalledWith({ a: 2 }); + }); + + it('registers the latest component class by name and resets compiled components', () => { + class Old {} + class Fresh {} + installViteHmrGlobals(jest.fn()); + + g.__NS_HMR_REGISTER_COMPONENT__('Cmp', Old); + g.__NS_HMR_REGISTER_COMPONENT__('Cmp', Fresh); + g.__reset_ng_compiled_components__(); + + expect(getHmrComponentClass('Cmp')).toBe(Fresh); + expect(resetCompiledComponents).toHaveBeenCalledTimes(1); + expect(hmrState().components.size).toBe(1); + }); +}); diff --git a/packages/angular/src/lib/hmr/hmr.ts b/packages/angular/src/lib/hmr/hmr.ts new file mode 100644 index 00000000..39489156 --- /dev/null +++ b/packages/angular/src/lib/hmr/hmr.ts @@ -0,0 +1,107 @@ +import { type Injector, ɵresetCompiledComponents } from '@angular/core'; +import { NativeScriptDebug } from '../trace'; + +/* + * Development-only HMR support for @nativescript/vite. + * + * Every caller outside this folder must guard its call with + * `typeof ngDevMode === 'undefined' || ngDevMode` so release builds + * (which define `ngDevMode` as false) drop this code entirely. + */ + +export type HmrPhase = 'beforeDispose' | 'afterBootstrap'; +export type HmrHooks = Partial void>>; + +export interface HmrRouteState { + /** URL to boot at on the next reboot. */ + pending?: string; + /** Named-outlet URL to navigate to once the reboot's initial navigation settles. */ + forward?: string; + /** URL being restored; set from the reboot until shortly after navigation settles. */ + restoring?: string; + timer?: ReturnType; +} + +interface HmrState { + hooks: Map; + components: Map; + route: HmrRouteState; + dialogs: unknown[]; + cache?: unknown; +} + +// One global slot so state survives even if this package is evaluated more than once per session +// (e.g. when it is linked from source and evicted along with app modules). +const STATE_KEY = '__NS_NG_HMR__'; + +export function hmrState(): HmrState { + const g = globalThis as unknown as Record; + return (g[STATE_KEY] ??= { hooks: new Map(), components: new Map(), route: {}, dialogs: [] }); +} + +/** Reads state without creating it, for public APIs that also run in release builds. */ +export function peekHmrState(): HmrState | undefined { + return (globalThis as unknown as Record)[STATE_KEY]; +} + +/** True inside a live @nativescript/vite HMR session (not a plain dev build). */ +export function isHmrActive(): boolean { + const g = globalThis as Record; + return !!(g.__NS_HOT_REGISTRY__ || g.__NS_DEV_PLACEHOLDER_ROOT_EARLY__ || g.__NS_HMR_BOOT_COMPLETE__); +} + +/** Registers (or replaces) hooks that run around every `hotreload` reboot. */ +export function registerHmrHooks(name: string, hooks: HmrHooks): void { + hmrState().hooks.set(name, hooks); +} + +export function runHmrHooks(phase: HmrPhase, injector: Injector | null | undefined): void { + if (!injector) { + return; + } + for (const [name, hooks] of hmrState().hooks) { + try { + hooks[phase]?.(injector); + } catch (err) { + NativeScriptDebug.hmrLogError(`${name}.${phase} failed: ${(err as Error)?.message ?? err}`); + } + } +} + +/** Latest class evaluated under `name`; Vite re-registers components each time their module re-evaluates. */ +export function getHmrComponentClass(name: string): T | undefined { + return hmrState().components.get(name) as T | undefined; +} + +export function hmrLog(message: string): void { + if (NativeScriptDebug.isLogEnabled()) { + NativeScriptDebug.hmrLog(message); + } +} + +/** + * Vite re-imports the app entry with `__NS_ANGULAR_HMR_REGISTER_ONLY__` set. That re-run of + * `runNativeScriptAngularApp` must only hand its fresh options to the running app. + * @returns true when the options were handed off and the caller must not boot. + */ +export function handOffHmrAppOptions(options: unknown): boolean { + const g = globalThis as Record; + if (g.__NS_ANGULAR_HMR_REGISTER_ONLY__ && typeof g.__NS_UPDATE_ANGULAR_APP_OPTIONS__ === 'function') { + g.__NS_UPDATE_ANGULAR_APP_OPTIONS__(options); + return true; + } + return false; +} + +/** Installs the globals the @nativescript/vite Angular HMR client calls. */ +export function installViteHmrGlobals(updateAppOptions: (options: never) => void): void { + const g = globalThis as Record; + g.__NS_UPDATE_ANGULAR_APP_OPTIONS__ = updateAppOptions; + // Called before re-importing changed modules so their components can be defined again without NG0912. + g.__reset_ng_compiled_components__ = () => ɵresetCompiledComponents(); + g.__NS_HMR_REGISTER_COMPONENT__ = (name: string, cls: unknown) => { + if (name && cls) { + hmrState().components.set(name, cls); + } + }; +} diff --git a/packages/angular/src/lib/hmr/modal-host.spec.ts b/packages/angular/src/lib/hmr/modal-host.spec.ts new file mode 100644 index 00000000..40e49a38 --- /dev/null +++ b/packages/angular/src/lib/hmr/modal-host.spec.ts @@ -0,0 +1,71 @@ +import { copyModalHostProps, ModalHostView, shareModalHostProps } from './modal-host'; + +class FakeView implements ModalHostView { + _dialogFragment?: unknown; + viewController?: unknown; + children: FakeView[] = []; + fragmentsAtAdd: unknown[] = []; + + eachChildView(callback: (child: ModalHostView) => boolean): void { + this.children.every((child) => callback(child) !== false); + } + + _addView(view: ModalHostView): void { + this.fragmentsAtAdd.push(view._dialogFragment); + this.children.push(view as FakeView); + } +} + +function tree() { + const wrapper = new FakeView(); + const host = new FakeView(); + const label = new FakeView(); + wrapper.children = [host]; + host.children = [label]; + return { wrapper, host, label }; +} + +describe('modal host props', () => { + it('copies the wrapper props onto every descendant', () => { + const { wrapper, host, label } = tree(); + wrapper._dialogFragment = { kind: 'fragment' }; + wrapper.viewController = { kind: 'controller' }; + + copyModalHostProps(wrapper, wrapper); + + for (const view of [host, label]) { + expect(view._dialogFragment).toBe(wrapper._dialogFragment); + expect(view.viewController).toBe(wrapper.viewController); + } + }); + + it('leaves descendants alone when the wrapper has no host props', () => { + const { wrapper, label } = tree(); + const own = { kind: 'own' }; + label._dialogFragment = own; + + copyModalHostProps(wrapper, wrapper); + + expect(label._dialogFragment).toBe(own); + }); + + it('stamps views the component host gains later, before they are added', () => { + const { wrapper, host } = tree(); + wrapper._dialogFragment = { kind: 'fragment' }; + shareModalHostProps(wrapper, host); + + const replacement = new FakeView(); + host._addView(replacement); + + expect(host.fragmentsAtAdd).toEqual([wrapper._dialogFragment]); + expect(host.children).toContain(replacement); + }); + + it('patches a host only once', () => { + const { wrapper, host } = tree(); + shareModalHostProps(wrapper, host); + const patched = host._addView; + shareModalHostProps(wrapper, host); + expect(host._addView).toBe(patched); + }); +}); diff --git a/packages/angular/src/lib/hmr/modal-host.ts b/packages/angular/src/lib/hmr/modal-host.ts new file mode 100644 index 00000000..c989ccef --- /dev/null +++ b/packages/angular/src/lib/hmr/modal-host.ts @@ -0,0 +1,54 @@ +export interface ModalHostView { + _dialogFragment?: unknown; + viewController?: unknown; + eachChildView?: (callback: (child: ModalHostView) => boolean) => void; + _addView?: (view: ModalHostView, atIndex?: number) => void; +} + +const ADD_VIEW_PATCHED = '__ng_hmr_modal_add_view__'; + +/* + * During HMR, modals present a stable wrapper instead of the component's first view so in-place + * template updates (ɵɵreplaceMetadata) can swap that view. Core only sets the modal host props + * (`_dialogFragment` on Android, `viewController` on iOS) on the presented view, and some lookups + * only check one parent up, so the props are mirrored onto everything inside the wrapper. + */ + +/** Copies the wrapper's modal host props onto every descendant of `root`. */ +export function copyModalHostProps(wrapper: ModalHostView, root: ModalHostView | undefined): void { + const { _dialogFragment, viewController } = wrapper; + if (!root || (_dialogFragment == null && viewController == null)) { + return; + } + const visit = (view: ModalHostView) => { + if (view !== wrapper) { + if (_dialogFragment != null) { + view._dialogFragment = _dialogFragment; + } + if (viewController != null) { + view.viewController = viewController; + } + } + view.eachChildView?.((child) => { + visit(child); + return true; + }); + }; + visit(root); +} + +/** Mirrors the wrapper's modal host props now and onto views the component host gains later. */ +export function shareModalHostProps(wrapper: ModalHostView, componentHost: ModalHostView | undefined): void { + copyModalHostProps(wrapper, wrapper); + const host = componentHost as (ModalHostView & Record) | undefined; + if (!host || host[ADD_VIEW_PATCHED] || typeof host._addView !== 'function') { + return; + } + const addView = host._addView.bind(host); + // Props must exist before _addView, which fires `loaded` on the new view. + host._addView = (view, atIndex) => { + copyModalHostProps(wrapper, view); + return addView(view, atIndex); + }; + host[ADD_VIEW_PATCHED] = true; +} diff --git a/packages/angular/src/lib/hmr/route.spec.ts b/packages/angular/src/lib/hmr/route.spec.ts new file mode 100644 index 00000000..4dfca2f8 --- /dev/null +++ b/packages/angular/src/lib/hmr/route.spec.ts @@ -0,0 +1,139 @@ +import { Subject } from 'rxjs'; + +class MockNavigationEnd {} +class MockNavigationCancel {} +class MockNavigationError {} +class MockRouter {} +class MockReuseStrategy {} + +jest.mock('@angular/core', () => ({ ɵresetCompiledComponents: jest.fn() })); +jest.mock('@angular/router', () => ({ + NavigationEnd: MockNavigationEnd, + NavigationCancel: MockNavigationCancel, + NavigationError: MockNavigationError, + Router: MockRouter, +})); +jest.mock('../legacy/router/ns-route-reuse-strategy', () => ({ NSRouteReuseStrategy: MockReuseStrategy })); +jest.mock('../trace', () => ({ + NativeScriptDebug: { isLogEnabled: () => false, hmrLog: jest.fn(), hmrLogError: jest.fn() }, +})); + +import { runHmrHooks } from './hmr'; +import { + clearRouteConfigCaches, + consumeHmrStartPath, + getAngularHmrRestoringRoute, + isAngularHmrRestoringRoute, + registerRouterHmrHooks, +} from './route'; + +function fakeRouter(url: string) { + const events = new Subject(); + const router = { + url, + config: [] as unknown[], + navigated: false, + events, + getCurrentNavigation: () => null, + navigateByUrl: jest.fn(() => Promise.resolve(true)), + }; + const reuse = { clearAllCaches: jest.fn() }; + const injector = { + get: (token: unknown) => (token === MockRouter ? router : token === MockReuseStrategy ? reuse : null), + } as any; + return { router, reuse, injector }; +} + +function reboot(url: string) { + const { injector } = fakeRouter(url); + runHmrHooks('beforeDispose', injector); +} + +beforeEach(() => { + jest.useFakeTimers(); + delete (globalThis as Record).__NS_NG_HMR__; + registerRouterHmrHooks(); +}); + +afterEach(() => jest.useRealTimers()); + +describe('route restore', () => { + it('has nothing to restore before a reboot', () => { + expect(consumeHmrStartPath()).toBeUndefined(); + expect(isAngularHmrRestoringRoute()).toBe(false); + }); + + it('boots at the URL captured before the reboot, once', () => { + const { reuse, injector } = fakeRouter('/settings/profile'); + runHmrHooks('beforeDispose', injector); + + expect(reuse.clearAllCaches).toHaveBeenCalled(); + expect(consumeHmrStartPath()).toBe('/settings/profile'); + expect(getAngularHmrRestoringRoute()).toBe('/settings/profile'); + expect(consumeHmrStartPath()).toBeUndefined(); + }); + + it('boots named-outlet URLs at / and replays them after the initial navigation', async () => { + reboot('/tabs/(home:feed//search:results)'); + expect(consumeHmrStartPath()).toBe('/'); + + const { router, injector } = fakeRouter('/'); + runHmrHooks('afterBootstrap', injector); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + + router.events.next(new MockNavigationEnd()); + await Promise.resolve(); + expect(router.navigateByUrl).toHaveBeenCalledWith('/tabs/(home:feed//search:results)'); + }); + + it('keeps the restore window open until shortly after navigation settles', async () => { + reboot('/detail/1'); + consumeHmrStartPath(); + const { router, injector } = fakeRouter('/detail/1'); + router.navigated = true; + + runHmrHooks('afterBootstrap', injector); + await Promise.resolve(); + expect(isAngularHmrRestoringRoute()).toBe(true); + + jest.advanceTimersByTime(1000); + expect(isAngularHmrRestoringRoute()).toBe(false); + }); + + it('re-captures the restore target when rebooting mid-restore', () => { + reboot('/a/(side:panel)'); + consumeHmrStartPath(); + + reboot('/'); + + expect(consumeHmrStartPath()).toBe('/'); + expect(getAngularHmrRestoringRoute()).toBe('/a/(side:panel)'); + }); + + it('ends the restore window if navigation never settles', () => { + reboot('/x'); + consumeHmrStartPath(); + jest.advanceTimersByTime(10000); + expect(isAngularHmrRestoringRoute()).toBe(false); + }); +}); + +describe('clearRouteConfigCaches', () => { + it('drops router-private caches, destroying cached injectors, and keeps public config', () => { + const destroy = jest.fn(); + const lazyChild: Record = { path: 'lazy', _loadedComponent: class {} }; + const route: Record = { + path: 'a', + data: { title: 'A' }, + _injector: { destroy }, + _loadedRoutes: [lazyChild], + children: [{ path: 'b', _loadedComponent: class {} }], + }; + + clearRouteConfigCaches([route]); + + expect(destroy).toHaveBeenCalled(); + expect(route).toEqual({ path: 'a', data: { title: 'A' }, children: [{ path: 'b' }] }); + expect(lazyChild).toEqual({ path: 'lazy' }); + }); +}); diff --git a/packages/angular/src/lib/hmr/route.ts b/packages/angular/src/lib/hmr/route.ts new file mode 100644 index 00000000..0f2ec7fa --- /dev/null +++ b/packages/angular/src/lib/hmr/route.ts @@ -0,0 +1,139 @@ +import type { Injector } from '@angular/core'; +import { NavigationCancel, NavigationEnd, NavigationError, Router } from '@angular/router'; +import { filter, take } from 'rxjs/operators'; +import { NSRouteReuseStrategy } from '../legacy/router/ns-route-reuse-strategy'; +import { hmrLog, hmrState, peekHmrState, registerHmrHooks } from './hmr'; + +const RESTORE_GRACE_MS = 1000; +const RESTORE_TIMEOUT_MS = 10000; +const NAMED_OUTLET_URL = /\([\w-]+:/; +// Router-private caches written onto route config objects. Route modules that are not re-evaluated +// keep their config objects across reboots, so these would otherwise pin classes from before the edit. +const ROUTE_CACHE_KEYS = [ + '_loadedComponent', + '_loadedInjector', + '_loadedNgModuleFactory', + '_loadedRoutes', + '_injector', +]; + +/** + * True while an HMR reboot is restoring the previous URL. Apps can check this to skip + * their own start-up navigation so it does not override the restored route. + */ +export function isAngularHmrRestoringRoute(): boolean { + return !!peekHmrState()?.route.restoring; +} + +/** URL being restored by HMR, or `null` when idle. */ +export function getAngularHmrRestoringRoute(): string | null { + return peekHmrState()?.route.restoring ?? null; +} + +export function registerRouterHmrHooks(): void { + registerHmrHooks('router', { beforeDispose: captureRoute, afterBootstrap: replayRoute }); +} + +/** + * Start path for a reboot, or undefined when there is nothing to restore. + * A named-outlet URL cannot be a start path, so it boots at `/` and is replayed afterwards. + */ +export function consumeHmrStartPath(): string | undefined { + const route = peekHmrState()?.route; + const target = route?.pending; + if (!target) { + return undefined; + } + route.pending = undefined; + route.restoring = target; + clearTimeout(route.timer); + route.timer = setTimeout(endRestore, RESTORE_TIMEOUT_MS); + if (NAMED_OUTLET_URL.test(target)) { + route.forward = target; + return '/'; + } + return target; +} + +function captureRoute(injector: Injector): void { + const router = injector.get(Router, null); + if (!router) { + return; + } + const route = hmrState().route; + // A reboot during a restore keeps the restore target rather than the interim `/`. + route.pending = route.restoring ?? router.url; + injector.get(NSRouteReuseStrategy, null)?.clearAllCaches(); + clearRouteConfigCaches(router.config); +} + +function replayRoute(injector: Injector): void { + const route = hmrState().route; + if (!route.restoring) { + return; + } + const router = injector.get(Router, null); + const forward = route.forward; + route.forward = undefined; + if (!router) { + endRestore(); + return; + } + const settled = async (succeeded: boolean) => { + if (forward && succeeded) { + await router.navigateByUrl(forward).catch(() => false); + } + clearTimeout(route.timer); + route.timer = setTimeout(endRestore, RESTORE_GRACE_MS); + }; + if (router.navigated && !router.getCurrentNavigation()) { + void settled(true); + return; + } + router.events + .pipe( + filter((e) => e instanceof NavigationEnd || e instanceof NavigationCancel || e instanceof NavigationError), + take(1), + ) + .subscribe((e) => void settled(e instanceof NavigationEnd)); +} + +function endRestore(): void { + const route = hmrState().route; + clearTimeout(route.timer); + route.timer = undefined; + route.forward = undefined; + if (route.restoring) { + hmrLog(`restored route ${route.restoring}`); + route.restoring = undefined; + } +} + +export function clearRouteConfigCaches(routes: unknown[] | undefined | null): void { + const seen = new Set(); + const visit = (route: Record | null | undefined) => { + if (!route || typeof route !== 'object' || seen.has(route)) { + return; + } + seen.add(route); + for (const child of [route.children, route._loadedRoutes]) { + if (Array.isArray(child)) { + child.forEach(visit); + } + } + for (const key of ROUTE_CACHE_KEYS) { + if (!(key in route)) { + continue; + } + if (key === '_injector' || key === '_loadedInjector') { + try { + (route[key] as { destroy?: () => void } | undefined)?.destroy?.(); + } catch { + // already destroyed with the app + } + } + delete route[key]; + } + }; + routes?.forEach(visit); +} diff --git a/packages/angular/src/lib/legacy/router/hmr-route.spec.ts b/packages/angular/src/lib/legacy/router/hmr-route.spec.ts deleted file mode 100644 index 0d9fe3da..00000000 --- a/packages/angular/src/lib/legacy/router/hmr-route.spec.ts +++ /dev/null @@ -1,333 +0,0 @@ -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, - ) {} -} -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', () => ({ - NavigationStart: MockNavigationStart, - NavigationEnd: MockNavigationEnd, - NavigationCancel: MockNavigationCancel, - NavigationError: MockNavigationError, - Router: class {}, -})); - -jest.mock('../../trace', () => ({ - NativeScriptDebug: { - isLogEnabled: () => false, - hmrLog: jest.fn(), - }, -})); - -jest.mock('../../hmr', () => ({ - isAngularHmrEnabled: () => true, -})); - -import { Subject } from 'rxjs'; -import { - beginAngularHmrRouteRestore, - captureAngularHmrPendingStartPath, - clearAngularHmrRouteConfigCaches, - cloneRoutesForBootstrap, - endAngularHmrRouteRestore, - getAngularHmrRestoringRoute, - installAngularHmrRouteCaptureHook, - invokeAngularHmrRouteCapture, - isAngularHmrRestoringRoute, - NativeScriptAngularHmrRouteReplay, - NativeScriptAngularHmrRouteTracker, - normalizeAngularHmrRouteUrl, - readAngularHmrCurrentRoute, - readAngularHmrPendingForwardNavigations, - readAngularHmrPendingStartPath, - resetAngularHmrRouteState, - writeAngularHmrRouteState, -} from './hmr-route'; - -interface RouterEvent { - url?: string; -} - -interface RouterMock { - events: Subject; - url: string; - navigateByUrl: jest.Mock, [string]>; - emitNavigationEnd(url: string): void; - emitNavigationCancel(url: string): void; -} - -function createRouterMock(initialUrl = '/'): RouterMock { - const events = new Subject(); - return { - events, - url: initialUrl, - navigateByUrl: jest.fn, [string]>(() => Promise.resolve(true)), - emitNavigationEnd(url) { - this.url = url; - events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent); - }, - emitNavigationCancel(url) { - events.next(new MockNavigationCancel(1, url, 'cancel') as unknown as RouterEvent); - }, - }; -} - -async function flushMicrotasks(rounds = 10): Promise { - for (let i = 0; i < rounds; i++) { - await Promise.resolve(); - } -} - -describe('Angular HMR route state', () => { - afterEach(() => { - resetAngularHmrRouteState(); - }); - - 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 start path and opens the restoring window', () => { - captureAngularHmrPendingStartPath('chatbot/42?mode=create'); - expect(readAngularHmrPendingStartPath()).toBe('/chatbot/42?mode=create'); - expect(isAngularHmrRestoringRoute()).toBe(true); - expect(getAngularHmrRestoringRoute()).toBe('/chatbot/42?mode=create'); - expect(readAngularHmrPendingForwardNavigations()).toEqual([]); - }); - - it('uses the capture hook before falling back to the last current URL', () => { - 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(); - } - writeAngularHmrRouteState('/profile?tab=goals', { source: 'navigation-end' }); - expect(invokeAngularHmrRouteCapture()).toBe('/profile?tab=goals'); - }); - - it('boots named-outlet URLs at / and defers one forward navigation', () => { - captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); - expect(readAngularHmrPendingStartPath()).toBe('/'); - expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); - expect(isAngularHmrRestoringRoute()).toBe(true); - expect(getAngularHmrRestoringRoute()).toBe('/talk/(todayTab:today)'); - }); - - it('does not open the restoring window when nothing is pending', () => { - expect(readAngularHmrPendingStartPath()).toBe(''); - expect(isAngularHmrRestoringRoute()).toBe(false); - }); - - it('opens and closes the restoring window on demand', () => { - beginAngularHmrRouteRestore('/profile?tab=goals'); - expect(isAngularHmrRestoringRoute()).toBe(true); - endAngularHmrRouteRestore(); - expect(isAngularHmrRestoringRoute()).toBe(false); - expect(getAngularHmrRestoringRoute()).toBeNull(); - }); -}); - -describe('cloneRoutesForBootstrap / route cache clear', () => { - it('drops private Angular router cache fields while preserving public 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[0].loadComponent).toBe(loadComponent); - expect(cloned[0].canActivate).not.toBe(canActivate); - expect(cloned[0]._loadedComponent).toBeUndefined(); - expect(cloned[0]['ɵrouterPageId']).toBeUndefined(); - expect(cloned[0].children[0]._loadedComponent).toBeUndefined(); - expect(cloneRoutesForBootstrap(undefined)).toEqual([]); - }); - - it('clears lazy route caches recursively without looping on shared children', () => { - const detailsInjectorDestroy = jest.fn(); - const grandchild = { - path: 'details', - _loadedComponent: { name: 'DetailsComponent' }, - _loadedInjector: { token: 'details', destroy: detailsInjectorDestroy }, - }; - const shared = { - path: 'shared', - _loadedComponent: { name: 'SharedComponent' }, - }; - const route = { - path: 'onboarding-flow', - children: [grandchild], - _loadedRoutes: [shared], - }; - const cleared = clearAngularHmrRouteConfigCaches([route, { path: 'b', _loadedRoutes: [shared] }]); - expect(cleared).toBe(5); - expect(detailsInjectorDestroy).toHaveBeenCalledTimes(1); - expect((shared as any)._loadedComponent).toBeUndefined(); - }); -}); - -describe('NativeScriptAngularHmrRouteTracker', () => { - afterEach(() => { - resetAngularHmrRouteState(); - }); - - it('does not treat bootstrap "/" as a pending restore URL', () => { - const router = createRouterMock('/'); - const tracker = new NativeScriptAngularHmrRouteTracker(router as never); - expect(readAngularHmrPendingStartPath()).toBe(''); - router.emitNavigationEnd('/talk/(todayTab:today)'); - expect(readAngularHmrCurrentRoute()).toBe('/talk/(todayTab:today)'); - tracker.ngOnDestroy(); - }); - - it('writes the current URL on NavigationEnd and captures it for reboot', () => { - const router = createRouterMock('/'); - const tracker = new NativeScriptAngularHmrRouteTracker(router as never); - router.emitNavigationEnd('/signup-landing'); - router.emitNavigationEnd('/login'); - router.emitNavigationEnd('/talk/(todayTab:today)'); - expect(readAngularHmrCurrentRoute()).toBe('/talk/(todayTab:today)'); - - const captureHook = (globalThis as { __NS_CAPTURE_ANGULAR_HMR_ROUTE__?: () => string | null }).__NS_CAPTURE_ANGULAR_HMR_ROUTE__; - expect(captureHook?.()).toBe('/talk/(todayTab:today)'); - expect(readAngularHmrPendingStartPath()).toBe('/'); - expect(readAngularHmrPendingForwardNavigations()).toEqual(['/talk/(todayTab:today)']); - tracker.ngOnDestroy(); - }); -}); - -describe('NativeScriptAngularHmrRouteReplay', () => { - beforeEach(() => { - jest.useFakeTimers(); - resetAngularHmrRouteState(); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - resetAngularHmrRouteState(); - }); - - it('keeps the restoring window open during the grace period after a named-outlet replay', async () => { - captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); - expect(readAngularHmrPendingStartPath()).toBe('/'); - const router = createRouterMock(); - const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/'); - await flushMicrotasks(); - await flushMicrotasks(); - expect(router.navigateByUrl).toHaveBeenCalledWith('/talk/(todayTab:today)'); - 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 deferred replay aborts', async () => { - captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); - readAngularHmrPendingStartPath(); - const router = createRouterMock(); - router.navigateByUrl.mockImplementation(() => Promise.resolve(false)); - const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/'); - await flushMicrotasks(); - await flushMicrotasks(); - await flushMicrotasks(); - expect(isAngularHmrRestoringRoute()).toBe(true); - jest.advanceTimersByTime(1000); - expect(isAngularHmrRestoringRoute()).toBe(false); - replay.ngOnDestroy(); - }); - - it('keeps a single-URL restore window open after the initial NavigationEnd', () => { - beginAngularHmrRouteRestore('/profile?tab=goals'); - const router = createRouterMock(); - const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/profile?tab=goals'); - expect(isAngularHmrRestoringRoute()).toBe(true); - jest.advanceTimersByTime(1000); - expect(isAngularHmrRestoringRoute()).toBe(false); - replay.ngOnDestroy(); - }); - - it('clears the deferred close timer when the service is destroyed', async () => { - captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); - readAngularHmrPendingStartPath(); - const router = createRouterMock(); - const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationEnd('/'); - await flushMicrotasks(); - await flushMicrotasks(); - replay.ngOnDestroy(); - expect(isAngularHmrRestoringRoute()).toBe(false); - beginAngularHmrRouteRestore('/somewhere/else'); - jest.advanceTimersByTime(2000); - expect(isAngularHmrRestoringRoute()).toBe(true); - endAngularHmrRouteRestore(); - }); - - it('closes the window immediately when the initial navigation fails', () => { - captureAngularHmrPendingStartPath('/talk/(todayTab:today)'); - readAngularHmrPendingStartPath(); - const router = createRouterMock(); - const replay = new NativeScriptAngularHmrRouteReplay(router as any); - router.emitNavigationCancel('/'); - expect(router.navigateByUrl).not.toHaveBeenCalled(); - expect(isAngularHmrRestoringRoute()).toBe(false); - replay.ngOnDestroy(); - }); -}); diff --git a/packages/angular/src/lib/legacy/router/hmr-route.ts b/packages/angular/src/lib/legacy/router/hmr-route.ts deleted file mode 100644 index 4b8580e4..00000000 --- a/packages/angular/src/lib/legacy/router/hmr-route.ts +++ /dev/null @@ -1,435 +0,0 @@ -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'; -import { NativeScriptDebug } from '../../trace'; - -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 PENDING_FORWARD_KEY = '__NS_ANGULAR_HMR_PENDING_FORWARD__'; -const CAPTURE_ROUTE_KEY = '__NS_CAPTURE_ANGULAR_HMR_ROUTE__'; -const RESTORING_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE__'; -const RESTORING_TARGET_KEY = '__NS_ANGULAR_HMR_RESTORING_ROUTE_TARGET__'; -const REPLAY_COMPLETED_GRACE_MS = 1000; - -function getGlobalState(): any { - return globalThis as any; -} - -function hasNamedOutletsInUrl(url: string): boolean { - return typeof url === 'string' && /\([A-Za-z0-9_-]+:/.test(url); -} - -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 readAngularHmrCurrentRoute(): string { - const g = getGlobalState(); - return normalizeAngularHmrRouteUrl(g[CURRENT_ROUTE_KEY]?.url ?? g[CURRENT_ROUTE_KEY]) || ''; -} - -function readPendingStartUrl(): string { - const g = getGlobalState(); - return normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || ''; -} - -function writePendingForward(url: string | null): void { - const g = getGlobalState(); - if (url) { - g[PENDING_FORWARD_KEY] = url; - } else { - delete g[PENDING_FORWARD_KEY]; - } -} - -export function readAngularHmrPendingStartPath(): string { - const target = readPendingStartUrl(); - if (!target) { - return ''; - } - beginAngularHmrRouteRestore(target); - if (hasNamedOutletsInUrl(target)) { - writePendingForward(target); - return '/'; - } - return target; -} - -export function readAngularHmrPendingForwardNavigations(): string[] { - const pending = getGlobalState()[PENDING_FORWARD_KEY]; - const target = typeof pending === 'string' ? pending : ''; - if (target && hasNamedOutletsInUrl(target)) { - return [target]; - } - const start = readPendingStartUrl(); - if (start && hasNamedOutletsInUrl(start)) { - return [start]; - } - return []; -} - -export function clearAngularHmrPendingForwardNavigation(): void { - delete getGlobalState()[PENDING_FORWARD_KEY]; -} - -export function invokeAngularHmrRouteCapture(): string | null { - const g = getGlobalState(); - const capture = g[CAPTURE_ROUTE_KEY]; - if (typeof capture === 'function') { - try { - return capture(); - } catch { - // ignore - } - } - 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]; - } - }; -} - -/** - * True while HMR is restoring the captured current URL. - */ -export function isAngularHmrRestoringRoute(): boolean { - return getGlobalState()[RESTORING_KEY] === true; -} - -/** - * Target URL currently being restored, or `null` when idle. - */ -export function getAngularHmrRestoringRoute(): string | null { - const value = getGlobalState()[RESTORING_TARGET_KEY]; - return typeof value === 'string' && value ? value : null; -} - -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]; - } -} - -export function endAngularHmrRouteRestore(): void { - const g = getGlobalState(); - delete g[RESTORING_KEY]; - delete g[RESTORING_TARGET_KEY]; -} - -export function resetAngularHmrRouteState(): void { - const g = getGlobalState(); - delete g[CURRENT_ROUTE_KEY]; - delete g[PENDING_START_PATH_KEY]; - delete g[PENDING_FORWARD_KEY]; - delete g[CAPTURE_ROUTE_KEY]; - endAngularHmrRouteRestore(); -} - -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 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 (key.startsWith('_') || key.startsWith('ɵ')) { - 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)); -} - -type AngularHmrRouteLike = { - children?: AngularHmrRouteLike[]; - _injector?: unknown; - _loadedComponent?: unknown; - _loadedInjector?: unknown; - _loadedNgModuleFactory?: unknown; - _loadedRoutes?: AngularHmrRouteLike[]; -}; - -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 { - // ignore - } - } -} - -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 { - try { - route[key] = undefined; - } catch { - // ignore - } - } - 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; -} - -@Injectable() -export class NativeScriptAngularHmrRouteTracker implements OnDestroy { - private subscription?: Subscription; - private disposeCaptureHook?: () => void; - - constructor(private readonly router: Router) { - if (!isAngularHmrEnabled()) { - return; - } - this.disposeCaptureHook = installAngularHmrRouteCaptureHook(() => this.captureCurrentRoute('hmr-reboot')); - 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, - }); - } -} - -@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(); - const restoringWindowOpen = isAngularHmrRestoringRoute(); - - if (forwardNavigations.length === 0) { - clearAngularHmrPendingForwardNavigation(); - if (restoringWindowOpen) { - 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')); - this.windowFallbackTimeout = setTimeout(() => this.closeRestoringWindow('fallback-timeout'), 5000); - } - return; - } - - 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.replayForwardNavigation(forwardNavigations[0]); - } else { - clearAngularHmrPendingForwardNavigation(); - this.closeRestoringWindow('initial-navigation-failed'); - } - }); - - 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; - } - 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})`); - } - } - - 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 replayForwardNavigation(url: string): Promise { - let aborted = false; - try { - const succeeded = await this.router.navigateByUrl(url).catch(() => false); - if (!succeeded) { - aborted = true; - } - } finally { - clearAngularHmrPendingForwardNavigation(); - this.scheduleRestoringWindowClose(aborted ? 'replay-aborted' : 'replay-completed'); - } - } -} diff --git a/packages/angular/src/lib/legacy/router/index.ts b/packages/angular/src/lib/legacy/router/index.ts index b8f6bd47..b5ad3cea 100644 --- a/packages/angular/src/lib/legacy/router/index.ts +++ b/packages/angular/src/lib/legacy/router/index.ts @@ -1,4 +1,4 @@ export { NSLocationStrategy } from './ns-location-strategy'; export { NSRouteReuseStrategy } from './ns-route-reuse-strategy'; export * from './router.module'; -export { getAngularHmrRestoringRoute, isAngularHmrRestoringRoute } from './hmr-route'; +export { getAngularHmrRestoringRoute, isAngularHmrRestoringRoute } from '../../hmr/route'; 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 deleted file mode 100644 index 48e4419e..00000000 --- a/packages/angular/src/lib/legacy/router/ns-location-strategy.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 95b2066c..07edcf87 100644 --- a/packages/angular/src/lib/legacy/router/ns-location-strategy.ts +++ b/packages/angular/src/lib/legacy/router/ns-location-strategy.ts @@ -33,26 +33,6 @@ 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 || '/'; @@ -703,6 +683,7 @@ export class NSLocationStrategy extends LocationStrategy implements OnDestroy { NativeScriptDebug.routerLog('NSLocationStrategy.ngOnDestroy()'); } - this.resetForHmr(); + this.outlets = []; + this.currentOutlet = null; } } 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 index db861bb5..fd2bff15 100644 --- 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 @@ -24,7 +24,7 @@ jest.mock('./page-router-outlet-utils', () => ({ import { NSRouteReuseStrategy } from './ns-route-reuse-strategy'; describe('NSRouteReuseStrategy', () => { - it('clears every cached outlet when destroyed', () => { + it('destroys and forgets every cached outlet', () => { const primaryClear = jest.fn(); const secondaryClear = jest.fn(); const strategy = new NSRouteReuseStrategy({} as any); @@ -34,24 +34,10 @@ describe('NSRouteReuseStrategy', () => { 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(); + strategy.clearAllCaches(); expect(primaryClear).toHaveBeenCalledTimes(1); + expect(secondaryClear).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 a887d144..9355162b 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, OnDestroy } from '@angular/core'; +import { Injectable } 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, OnDestroy { +export class NSRouteReuseStrategy implements RouteReuseStrategy { private cacheByOutlet: { [key: string]: DetachedStateCache } = {}; constructor(private location: NSLocationStrategy) {} @@ -330,18 +330,11 @@ export class NSRouteReuseStrategy implements RouteReuseStrategy, OnDestroy { } } - clearAllCaches(): number { - const outletKeys = Object.keys(this.cacheByOutlet); - - for (const outletKey of outletKeys) { - this.cacheByOutlet[outletKey]?.clear(); - delete this.cacheByOutlet[outletKey]; + /** Destroys every detached route in every outlet. */ + clearAllCaches(): void { + for (const key of Object.keys(this.cacheByOutlet)) { + this.cacheByOutlet[key].clear(); + delete this.cacheByOutlet[key]; } - - 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 6a9c0f9f..790d1116 100644 --- a/packages/angular/src/lib/legacy/router/router.module.ts +++ b/packages/angular/src/lib/legacy/router/router.module.ts @@ -1,13 +1,10 @@ import { - APP_BOOTSTRAP_LISTENER, - ENVIRONMENT_INITIALIZER, NgModule, ModuleWithProviders, NO_ERRORS_SCHEMA, Optional, Provider, SkipSelf, - inject, makeEnvironmentProviders, } from '@angular/core'; import { @@ -34,7 +31,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, NativeScriptAngularHmrRouteReplay, NativeScriptAngularHmrRouteTracker, readAngularHmrPendingStartPath } from './hmr-route'; +import { consumeHmrStartPath, registerRouterHmrHooks } from '../../hmr/route'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; export { PageRoute } from './page-router-outlet'; @@ -62,7 +59,14 @@ export function provideLocationStrategy( frameService: FrameService, startPath: string, ): NSLocationStrategy { - return locationStrategy ? locationStrategy : new NSLocationStrategy(frameService, startPath); + if (locationStrategy) { + return locationStrategy; + } + if (typeof ngDevMode === 'undefined' || ngDevMode) { + registerRouterHmrHooks(); + startPath = consumeHmrStartPath() ?? startPath; + } + return new NSLocationStrategy(frameService, startPath); } const ROUTER_COMPONENTS = [NSRouterLink, NSRouterLinkActive, PageRouterOutlet, NSEmptyOutletComponent]; @@ -77,11 +81,7 @@ export class NativeScriptRouterModule { return { ngModule: NativeScriptRouterModule, providers: [ - ...RouterModule.forRoot(cloneRoutesForBootstrap(routes), config).providers, - { - provide: START_PATH, - useFactory: readAngularHmrPendingStartPath, - }, + ...RouterModule.forRoot(routes, config).providers, { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -93,14 +93,6 @@ export class NativeScriptRouterModule { RouterExtensions, NSRouteReuseStrategy, { provide: RouteReuseStrategy, useExisting: NSRouteReuseStrategy }, - NativeScriptAngularHmrRouteTracker, - NativeScriptAngularHmrRouteReplay, - { - provide: APP_BOOTSTRAP_LISTENER, - multi: true, - deps: [NativeScriptAngularHmrRouteTracker, NativeScriptAngularHmrRouteReplay], - useFactory: () => () => undefined, - }, config?.bindToComponentInputs ? inputBinderProviders(typeof config.bindToComponentInputs === 'object' ? config.bindToComponentInputs : {}) : [], @@ -109,7 +101,7 @@ export class NativeScriptRouterModule { } static forChild(routes: Routes): ModuleWithProviders { - return { ngModule: NativeScriptRouterModule, providers: RouterModule.forChild(cloneRoutesForBootstrap(routes)).providers }; + return { ngModule: NativeScriptRouterModule, providers: RouterModule.forChild(routes).providers }; } } export function rootRoute(router: Router): ActivatedRoute { @@ -119,11 +111,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(cloneRoutesForBootstrap(routes), ...features), - { - provide: START_PATH, - useFactory: readAngularHmrPendingStartPath, - }, + provideRouter(routes, ...features), { provide: NSLocationStrategy, useFactory: provideLocationStrategy, @@ -135,17 +123,6 @@ export function provideNativeScriptRouter(routes: Routes, ...features: RouterFea RouterExtensions, 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}, hasInputBinding ? inputBinderProviders() : [], ]); } diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 80f7537a..34c4cb27 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -107,7 +107,6 @@ function modifiesDom() { export class NativeScriptRendererFactory implements RendererFactory2 { private componentRenderers = new Map(); - private componentStyleSignatures = new Map(); private defaultRenderer: Renderer2; // backwards compatibility with RadListView private rootView = inject(APP_ROOT_VIEW); @@ -152,16 +151,8 @@ export class NativeScriptRendererFactory implements RendererFactory2 { if (renderer instanceof EmulatedRenderer) { renderer.applyToHost(hostElement); } - - // Cached renderer skips addStyles; re-apply when styles changed. - 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); - } + if (typeof ngDevMode === 'undefined' || ngDevMode) { + reapplyChangedStyles(type, this.rootModuleID); } return renderer; @@ -178,27 +169,10 @@ export class NativeScriptRendererFactory implements RendererFactory2 { } this.componentRenderers.set(type.id, renderer); - this.componentStyleSignatures.set(type.id, this.styleSignature(type.styles)); - return renderer; - } - - private styleSignature(styles: (string | any[])[]): string { - try { - return (styles || []).map((s) => s.toString()).join("\n"); - } catch { - return ''; - } - } - - 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}`); - } + if (typeof ngDevMode === 'undefined' || ngDevMode) { + reapplyChangedStyles(type, this.rootModuleID); } + return renderer; } begin() { if (__APPLE__ && this.wrapCdInTransaction) { @@ -515,35 +489,46 @@ const addScopedStyleToCss = profile( }, ); +let styleSignatures: Map | undefined; + +/** + * Renderers are cached per component id, so styles changed by in-place component HMR + * (ɵɵreplaceMetadata keeps the id) would otherwise never reach the CSS. Dev builds only. + */ +function reapplyChangedStyles(type: RendererType2, rootModuleID: string | number): void { + styleSignatures ??= new Map(); + const key = `${rootModuleID}:${type.id}`; + const signature = type.styles.join('\n'); + const previous = styleSignatures.get(key); + styleSignatures.set(key, signature); + if (previous === undefined || previous === signature) { + return; + } + const styles = type.styles.map((s) => s.toString()); + if (type.encapsulation === ViewEncapsulation.None) { + styles.forEach((s) => addStyleToCss(s, rootModuleID)); + } else { + const componentId = type.id.replace(ATTR_SANITIZER, '_'); + styles.forEach((s) => addScopedStyleToCss(replaceNgAttribute(s, componentId), rootModuleID)); + } + Application.getRootView()?._onCssStateChange(); +} + @Injectable() 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 emulated styles after an HMR style edit. - */ - reapplyStyles(styles: (string | any[])[]): void { - this.addStyles(styles, this.componentId); - try { - Application.getRootView()?._onCssStateChange(); - } catch { - // ignore - } - } - applyToHost(view: NgView) { super.setAttribute(view, this.hostAttr, ''); } diff --git a/packages/angular/src/lib/platform-nativescript.ts b/packages/angular/src/lib/platform-nativescript.ts index cabef5e6..3989a1ff 100644 --- a/packages/angular/src/lib/platform-nativescript.ts +++ b/packages/angular/src/lib/platform-nativescript.ts @@ -22,7 +22,6 @@ 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 { @@ -205,12 +204,6 @@ 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), @@ -218,7 +211,6 @@ export function bootstrapApplication( } export function createApplication(options?: NativeScriptApplicationConfig, context?: BootstrapContext) { - registerNativeScriptViewComponents(); return ɵinternalCreateApplication(createProvidersConfig(options, context)); } @@ -260,7 +252,7 @@ export interface AppOptions { * @deprecated use runNativeScriptAngularApp instead */ export const platformNativeScriptDynamic = function (options?: AppOptions, extraProviders?: StaticProvider[]) { - console.warn('platformNativeScriptDynamic is deprecated, use runNativeScriptAngularApp instead'); + console.log('platformNativeScriptDynamic is deprecated, use runNativeScriptAngularApp instead'); options = options || {}; extraProviders = extraProviders || []; diff --git a/packages/angular/src/lib/public_api.ts b/packages/angular/src/lib/public_api.ts index 18a45fb9..7ba0c145 100644 --- a/packages/angular/src/lib/public_api.ts +++ b/packages/angular/src/lib/public_api.ts @@ -42,7 +42,7 @@ export * from './file-system'; export * from './nativescript-common.module'; export * from './loading.service'; export * from './detached-loader-utils'; -export { HmrCacheService, configureHmrCache, type HmrCacheOptions, type HmrCacheScope } from './hmr-cache'; +export { HmrCacheService, configureHmrCache, type HmrCacheOptions, type HmrCacheScope } from './hmr/cache'; // export * from './router/router.module'; export { AppLaunchView, diff --git a/packages/angular/src/lib/view-util.ts b/packages/angular/src/lib/view-util.ts index e6819cc9..7a47970a 100644 --- a/packages/angular/src/lib/view-util.ts +++ b/packages/angular/src/lib/view-util.ts @@ -37,7 +37,9 @@ function printNgTree(view: NgView) { function printChildrenRecurse(parent: NgView) { const children = parent.firstChild ? [parent.firstChild, ...getChildrenSiblings(parent.firstChild).nextSiblings] : []; if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.viewUtilLog(`parent: ${parent}, firstChild: ${parent.firstChild}, lastChild: ${parent.lastChild} children: ${children}`); + NativeScriptDebug.viewUtilLog( + `parent: ${parent}, firstChild: ${parent.firstChild}, lastChild: ${parent.lastChild} children: ${children}`, + ); if (parent.firstChild) { NativeScriptDebug.viewUtilLog(`----- start ${parent}`); } diff --git a/packages/zone-js/dist/connectivity.ts b/packages/zone-js/dist/connectivity.ts index 4eb078c6..8da43200 100644 --- a/packages/zone-js/dist/connectivity.ts +++ b/packages/zone-js/dist/connectivity.ts @@ -10,6 +10,6 @@ Zone.__load_patch('nativescript_connectivity', (global, zone, api) => { 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 index c590e8e3..8b0d1c02 100644 --- a/packages/zone-js/dist/core.ts +++ b/packages/zone-js/dist/core.ts @@ -14,11 +14,7 @@ function isPropertyWritable(propertyDesc: any) { } 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 { + 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); diff --git a/packages/zone-js/dist/events.ts b/packages/zone-js/dist/events.ts index b51d20a7..824c5b6c 100644 --- a/packages/zone-js/dist/events.ts +++ b/packages/zone-js/dist/events.ts @@ -19,7 +19,7 @@ Zone.__load_patch('nativescript_mainThreadify', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS mainThreadify patch')]); - }, + } ); }); @@ -31,7 +31,7 @@ Zone.__load_patch('nativescript_executeOnMainThread', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS executeOnMainThread patch')]); - }, + } ); }); @@ -43,7 +43,7 @@ Zone.__load_patch('nativescript_dispatchToMainThread', (global, zone, api) => { function (self, args) { const callback = args[0]; return delegate.apply(self, [Zone.current.wrap(callback, 'NS dispatchToMainThread patch')]); - }, + } ); }); @@ -62,7 +62,7 @@ Zone.__load_patch('nativescript_showModal', (global, zone, api) => { args[3] = Zone.current.wrap(args[3], 'NS showModal patch'); } return delegate.apply(self, args); - }, + } ); }); diff --git a/packages/zone-js/dist/utils.ts b/packages/zone-js/dist/utils.ts index d477c105..84df0d6b 100644 --- a/packages/zone-js/dist/utils.ts +++ b/packages/zone-js/dist/utils.ts @@ -58,12 +58,7 @@ export interface PatchEventTargetOptions { transferEventName?: (eventName: string) => string; } -export function patchNativeScriptEventTarget( - global: any, - api: _ZonePrivate, - apis?: any[], - patchOptions?: PatchEventTargetOptions, -) { +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'; @@ -154,13 +149,7 @@ export function patchNativeScriptEventTarget( nsTaskData: taskData, }; const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask( - objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), - callback, - data, - schedule, - unschedule, - ); + 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; @@ -185,7 +174,7 @@ export function patchNativeScriptEventTarget( } else { addSingleEvent(originalTarget, originalArgs); } - }, + } ); const nativeOnce = api.patchMethod( @@ -229,11 +218,7 @@ export function patchNativeScriptEventTarget( 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, - ); + task.target[REMOVE_EVENT_LISTENER](task.eventName, task.callback, task.thisArg ? task.thisArg.get() : undefined); }; const args2 = [taskData.eventName, task.invoke]; if (taskData.thisArg) { @@ -255,13 +240,7 @@ export function patchNativeScriptEventTarget( nsTaskData: taskData, }; const objName = obj.name || obj?.constructor?.name; - const task: ExtendedTask = Zone.current.scheduleEventTask( - objName + ':' + (eventNameToString ? eventNameToString(eventName) : eventName), - callback, - data, - schedule, - unschedule, - ); + 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; @@ -276,10 +255,7 @@ export function patchNativeScriptEventTarget( task.eventName = eventName; existingTasks.push(task); }; - const events: string[] = - originalArgs && Array.isArray(originalArgs) && typeof originalArgs[0] === 'string' - ? originalArgs[0].split(',') - : []; + 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); @@ -290,7 +266,7 @@ export function patchNativeScriptEventTarget( } else { addSingleEvent(originalTarget, originalArgs); } - }, + } ); const nativeRemoveListener = api.patchMethod( @@ -344,7 +320,7 @@ export function patchNativeScriptEventTarget( } else { removeSingleEvent(originalTarget, originalArgs); } - }, + } ); } diff --git a/packages/zone-js/dist/xhr.ts b/packages/zone-js/dist/xhr.ts index 29bdd731..2e6c13ab 100644 --- a/packages/zone-js/dist/xhr.ts +++ b/packages/zone-js/dist/xhr.ts @@ -14,13 +14,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr /** 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 { + 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. @@ -148,7 +142,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr self[XHR_SYNC] = args[2] == false; self[XHR_URL] = args[1]; return openNative!.apply(self, args); - }, + } ); const XMLHTTPREQUEST_SOURCE = 'XMLHttpRequest.send'; @@ -169,20 +163,8 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr // 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, - ); + 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 @@ -190,7 +172,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr task.invoke(); } } - }, + } ); const abortNative = api.patchMethod( @@ -215,7 +197,7 @@ Zone.__load_patch('nativescript_XHR', (global: any, Zone: ZoneType, api: _ZonePr // Otherwise, we are trying to abort an XHR which has not yet been sent, so there is no // task // to cancel. Do nothing. - }, + } ); } });