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..a8efc1de 100644 --- a/packages/angular/src/lib/application.ts +++ b/packages/angular/src/lib/application.ts @@ -14,6 +14,7 @@ import { import { Observable, Subject } from 'rxjs'; import { filter, map, take } from 'rxjs/operators'; import { AppHostView } from './app-host-view'; +import { handOffHmrAppOptions, hmrLog, installViteHmrGlobals, runHmrHooks } from './hmr/hmr'; import { NativeScriptLoadingService } from './loading.service'; import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens'; import { NativeScriptDebug } from './trace'; @@ -220,6 +221,9 @@ export interface ApplicationConfig { } export function runNativeScriptAngularApp(options: AppRunOptions) { + if ((typeof ngDevMode === 'undefined' || ngDevMode) && handOffHmrAppOptions(options)) { + return; + } let mainModuleRef: NgModuleRef | ApplicationRef = null; let loadingModuleRef: NgModuleRef | ApplicationRef; let platformRef: PlatformRef = null; @@ -274,6 +278,10 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { Application.run({ create: () => newRoot }); } else if (launchEventDone) { Application.resetRootView({ create: () => newRoot }); + 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 { targetRootView = newRoot; } @@ -303,20 +311,37 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { ref.destroy(); return; } - mainModuleRef = ref; + const onBootstrapped = () => { + 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); - }); - bootstrapped = true; - onMainBootstrap(); - emitModuleBootstrapEvent(ref, 'main', reason); - // bootstrapped component: (ref as any)._bootstrapComponents[0]; + (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); + }); + 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; @@ -427,6 +452,9 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { platformRef = null; }; const disposeLastModules = (reason: NgModuleReason) => { + 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); @@ -457,8 +485,8 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { if (oldAddEventListener) { global.NativeScriptGlobals.events.addEventListener = oldAddEventListener; } - if (import.meta['webpackHot']) { - // handle HMR Application.run + if (typeof ngDevMode === 'undefined' || ngDevMode) { + // Hooks for webpack and @nativescript/vite HMR. global['__dispose_app_ng_platform__'] = () => { disposePlatform('hotreload'); }; @@ -475,19 +503,20 @@ export function runNativeScriptAngularApp(options: AppRunOptions) { 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 (!Application.hasLaunched()) { - Application.run(); + // 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; } if (options.embedded) { 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-services.ts b/packages/angular/src/lib/cdk/dialog/dialog-services.ts index 13417ccf..3c3caf08 100644 --- a/packages/angular/src/lib/cdk/dialog/dialog-services.ts +++ b/packages/angular/src/lib/cdk/dialog/dialog-services.ts @@ -18,6 +18,7 @@ import { } from '@angular/core'; import { defer, Observable, Subject } from 'rxjs'; import { startWith } from 'rxjs/operators'; +import { trackHmrDialog } from '../../hmr/dialog'; import { NSLocationStrategy } from '../../legacy/router/ns-location-strategy'; import { ComponentType } from '../../utils/general'; import { ComponentPortal, TemplatePortal } from '../portal/common'; @@ -110,6 +111,9 @@ export class NativeDialog implements OnDestroy { this.openDialogs.push(dialogRef); 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. 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 3852d237..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'; @@ -19,6 +21,8 @@ export class NativeModalRef { portalOutlet: NativeScriptDomPortalOutlet; detachedLoaderRef: ComponentRef; modalViewRef: NgViewRef; + /** View presented with showModal when it is not `modalViewRef.firstNativeLikeView`. */ + modalView?: View; private _closeCallback: () => void; private _isDismissed = false; @@ -51,7 +55,7 @@ export class NativeModalRef { this._closeCallback = once(() => { this.stateChanged.next({ state: 'closing' }); if (!this._isDismissed) { - this.modalViewRef.firstNativeLikeView?.closeModal(); + (this.modalView ?? this.modalViewRef.firstNativeLikeView)?.closeModal(); } }); } @@ -102,6 +106,19 @@ export class NativeModalRef { (this.modalViewRef.view)._ngDialogRoot = this.modalViewRef.firstNativeLikeView; } this.modalViewRef.firstNativeLikeView['__ng_modal_id__'] = this._id; + + 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(); @@ -142,7 +159,7 @@ 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; + 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(); 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.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/index.ts b/packages/angular/src/lib/legacy/router/index.ts index 52cf4562..b5ad3cea 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-route-reuse-strategy.spec.ts b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts new file mode 100644 index 00000000..fd2bff15 --- /dev/null +++ b/packages/angular/src/lib/legacy/router/ns-route-reuse-strategy.spec.ts @@ -0,0 +1,43 @@ +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('destroys and forgets every cached outlet', () => { + const primaryClear = jest.fn(); + const secondaryClear = jest.fn(); + const strategy = new NSRouteReuseStrategy({} as any); + + (strategy as any).cacheByOutlet = { + primary: { clear: primaryClear }, + secondary: { clear: secondaryClear }, + }; + + strategy.clearAllCaches(); + + expect(primaryClear).toHaveBeenCalledTimes(1); + expect(secondaryClear).toHaveBeenCalledTimes(1); + expect((strategy as any).cacheByOutlet).toEqual({}); + }); +}); 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..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 @@ -329,4 +329,12 @@ export class NSRouteReuseStrategy implements RouteReuseStrategy { cache.clearModalCache(); } } + + /** 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]; + } + } } diff --git a/packages/angular/src/lib/legacy/router/router.module.ts b/packages/angular/src/lib/legacy/router/router.module.ts index b0171695..790d1116 100644 --- a/packages/angular/src/lib/legacy/router/router.module.ts +++ b/packages/angular/src/lib/legacy/router/router.module.ts @@ -31,6 +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 { consumeHmrStartPath, registerRouterHmrHooks } from '../../hmr/route'; import { ComponentInputBindingOptions, INPUT_BINDER, RoutedComponentInputBinder } from './router-component-input-binder'; export { PageRoute } from './page-router-outlet'; @@ -58,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]; diff --git a/packages/angular/src/lib/nativescript-renderer.ts b/packages/angular/src/lib/nativescript-renderer.ts index 03ef3af8..34c4cb27 100644 --- a/packages/angular/src/lib/nativescript-renderer.ts +++ b/packages/angular/src/lib/nativescript-renderer.ts @@ -151,6 +151,9 @@ export class NativeScriptRendererFactory implements RendererFactory2 { if (renderer instanceof EmulatedRenderer) { renderer.applyToHost(hostElement); } + if (typeof ngDevMode === 'undefined' || ngDevMode) { + reapplyChangedStyles(type, this.rootModuleID); + } return renderer; } @@ -166,6 +169,9 @@ export class NativeScriptRendererFactory implements RendererFactory2 { } this.componentRenderers.set(type.id, renderer); + if (typeof ngDevMode === 'undefined' || ngDevMode) { + reapplyChangedStyles(type, this.rootModuleID); + } return renderer; } begin() { @@ -483,6 +489,31 @@ 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; diff --git a/packages/angular/src/lib/public_api.ts b/packages/angular/src/lib/public_api.ts index fa617f87..7ba0c145 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..7a47970a 100644 --- a/packages/angular/src/lib/view-util.ts +++ b/packages/angular/src/lib/view-util.ts @@ -36,15 +36,17 @@ 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 +70,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