Skip to content

Commit e1ec30d

Browse files
committed
feat: improve hmr conditions around routing
1 parent 8e8c6c1 commit e1ec30d

12 files changed

Lines changed: 396 additions & 24 deletions

packages/angular/src/lib/application.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ import {
2020
getAngularCoreForHmrReset,
2121
rememberAngularCoreForHmr,
2222
resetAngularHmrCompiledComponents,
23+
setAngularCoreForHmr,
2324
} from './hmr-compiled-components-core';
2425
import { NativeScriptLoadingService } from './loading.service';
2526
import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core';
27+
import { NSLocationStrategy } from './legacy/router/ns-location-strategy';
28+
import { NSRouteReuseStrategy } from './legacy/router/ns-route-reuse-strategy';
2629
import { createAngularRootTransitionGuard } from './root-transition-guard';
2730
import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens';
2831
import { NativeScriptDebug } from './trace';
@@ -32,6 +35,9 @@ import { NativeScriptDebug } from './trace';
3235
// We need to use the original one that has the registered LViews
3336
rememberAngularCoreForHmr(AngularCore as any, globalThis as any);
3437

38+
const angularHmrGlobal = globalThis as any;
39+
angularHmrGlobal.__NS_REMEMBER_ANGULAR_CORE__ = (core: any) => setAngularCoreForHmr(core, angularHmrGlobal);
40+
3541
export interface AppLaunchView extends LayoutBase {
3642
// called when the animation is to begin
3743
startAnimation?: () => void;
@@ -234,18 +240,43 @@ export interface ApplicationConfig {
234240
}
235241

236242
export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
243+
const hmrGlobal = globalThis as any;
244+
245+
if (hmrGlobal.__NS_ANGULAR_HMR_REGISTER_ONLY__ && typeof hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__ === 'function') {
246+
hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__(options);
247+
return;
248+
}
249+
250+
let currentOptions = options;
237251
let mainModuleRef: NgModuleRef<T> | ApplicationRef = null;
238252
let loadingModuleRef: NgModuleRef<K> | ApplicationRef;
239253
let platformRef: PlatformRef = null;
240254
let bootstrapId = -1;
255+
256+
hmrGlobal.__NS_UPDATE_ANGULAR_APP_OPTIONS__ = (nextOptions: AppRunOptions<T, K>) => {
257+
currentOptions = nextOptions;
258+
};
259+
241260
const clearAngularHmrRouteCaches = () => {
242261
try {
243262
const injector = (mainModuleRef as any)?.injector;
263+
const reuseStrategy = injector?.get?.(NSRouteReuseStrategy, null);
264+
const locationStrategy = injector?.get?.(NSLocationStrategy, null);
244265
const router = injector?.get?.(Router, null);
266+
const clearedDetached = reuseStrategy?.clearAllCaches?.() ?? 0;
267+
const clearedLocation = locationStrategy?.resetForHmr?.() ?? null;
245268
const cleared = clearAngularHmrRouteConfigCaches(router?.config);
246269

247-
if (cleared > 0) {
248-
console.log('[ng-hmr] cleared Angular route caches before reboot:', cleared);
270+
if (
271+
clearedDetached > 0 ||
272+
cleared > 0 ||
273+
(clearedLocation && (clearedLocation.outlets > 0 || clearedLocation.states > 0 || clearedLocation.callbacks > 0 || clearedLocation.hadUrlTree))
274+
) {
275+
console.log('[ng-hmr] cleared Angular route caches before reboot:', {
276+
detachedViews: clearedDetached,
277+
locationState: clearedLocation,
278+
routeFields: cleared,
279+
});
249280
}
250281
} catch {}
251282
};
@@ -299,7 +330,7 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
299330
if (NativeScriptDebug.isLogEnabled()) {
300331
NativeScriptDebug.bootstrapLog(`Setting RootView to ${ref}`);
301332
}
302-
if (options.embedded) {
333+
if (currentOptions.embedded) {
303334
Application.run({ create: () => ref });
304335
} else if (launchEventDone) {
305336
rootTransitionGuard.runApplicationResetRootView(Application, () => ref, ref?.constructor?.name || 'View');
@@ -317,11 +348,11 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
317348
'newRoot:',
318349
newRoot?.constructor?.name,
319350
);
320-
console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', options.embedded);
351+
console.log('[ng-hmr] setRootView: launchEventDone:', launchEventDone, 'embedded:', currentOptions.embedded);
321352
if (NativeScriptDebug.isLogEnabled()) {
322353
NativeScriptDebug.bootstrapLog(`Setting RootView to ${newRoot}`);
323354
}
324-
if (options.embedded) {
355+
if (currentOptions.embedded) {
325356
console.log('[ng-hmr] setRootView: calling Application.run (embedded)');
326357
Application.run({ create: () => newRoot });
327358
} else if (launchEventDone) {
@@ -372,7 +403,7 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
372403
};
373404
runSynchronously(
374405
() =>
375-
options.appModuleBootstrap(reason).then(
406+
currentOptions.appModuleBootstrap(reason).then(
376407
(ref) => {
377408
console.log('[ng-hmr] appModuleBootstrap resolved, ref:', ref?.constructor?.name);
378409
console.log('[ng-hmr] currentBootstrapId:', currentBootstrapId, 'bootstrapId:', bootstrapId);
@@ -482,9 +513,9 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
482513
return;
483514
}
484515
if (!bootstrapped) {
485-
if (options.loadingModule) {
516+
if (currentOptions.loadingModule) {
486517
runSynchronously(() =>
487-
options.loadingModule(reason).then(
518+
currentOptions.loadingModule(reason).then(
488519
(loadingRef) => {
489520
if (currentBootstrapId !== bootstrapId) {
490521
// this module is old and not needed anymore
@@ -536,8 +567,8 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
536567
},
537568
),
538569
);
539-
} else if (options.launchView) {
540-
let launchView = options.launchView(reason);
570+
} else if (currentOptions.launchView) {
571+
let launchView = currentOptions.launchView(reason);
541572
setRootView(launchView);
542573
if (launchView.startAnimation) {
543574
setTimeout(() => {
@@ -606,7 +637,7 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
606637
global.NativeScriptGlobals.events.addEventListener =
607638
global.NativeScriptGlobals.events[Zone.__symbol__('addEventListener')];
608639
}
609-
if (!options.embedded) {
640+
if (!currentOptions.embedded) {
610641
Application.on(Application.launchEvent, launchCallback);
611642
}
612643
Application.on(Application.exitEvent, exitCallback);
@@ -681,7 +712,7 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
681712
return;
682713
}
683714

684-
if (options.embedded) {
715+
if (currentOptions.embedded) {
685716
bootstrapRoot('applaunch');
686717
} else {
687718
Application.run();

packages/angular/src/lib/hmr-compiled-components-core.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
getAngularCoreForHmrReset,
33
rememberAngularCoreForHmr,
44
resetAngularHmrCompiledComponents,
5+
setAngularCoreForHmr,
56
} from './hmr-compiled-components-core';
67

78
describe('Angular HMR compiled component reset', () => {
@@ -57,4 +58,19 @@ describe('Angular HMR compiled component reset', () => {
5758
expect(rememberAngularCoreForHmr(replacementCore, globalObj)).toBe(originalCore);
5859
expect(globalObj.__NS_ANGULAR_CORE__).toBe(originalCore);
5960
});
61+
62+
it('allows the active Angular core realm to be updated explicitly for HMR resets', () => {
63+
const originalCore = {
64+
ɵresetCompiledComponents: jest.fn(),
65+
};
66+
const replacementCore = {
67+
ɵresetCompiledComponents: jest.fn(),
68+
};
69+
const globalObj: any = {
70+
__NS_ANGULAR_CORE__: originalCore,
71+
};
72+
73+
expect(setAngularCoreForHmr(replacementCore, globalObj)).toBe(replacementCore);
74+
expect(globalObj.__NS_ANGULAR_CORE__).toBe(replacementCore);
75+
});
6076
});

packages/angular/src/lib/hmr-compiled-components-core.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ type AngularCoreHolder = {
66
__NS_ANGULAR_CORE__?: AngularCoreWithCompiledComponentReset | null;
77
};
88

9+
export function setAngularCoreForHmr(
10+
core: AngularCoreWithCompiledComponentReset | null | undefined,
11+
globalObj: AngularCoreHolder = globalThis as AngularCoreHolder,
12+
): AngularCoreWithCompiledComponentReset | null | undefined {
13+
if (core) {
14+
globalObj.__NS_ANGULAR_CORE__ = core;
15+
}
16+
17+
return getAngularCoreForHmrReset(core, globalObj);
18+
}
19+
920
export function getAngularCoreForHmrReset(
1021
core: AngularCoreWithCompiledComponentReset | null | undefined,
1122
globalObj: AngularCoreHolder = globalThis as AngularCoreHolder,
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
type AngularBootstrapRouteLike = {
2+
children?: AngularBootstrapRouteLike[];
3+
};
4+
5+
function isPlainObject(value: unknown): value is Record<string, unknown> {
6+
if (!value || typeof value !== 'object') {
7+
return false;
8+
}
9+
10+
const proto = Object.getPrototypeOf(value);
11+
return proto === Object.prototype || proto === null;
12+
}
13+
14+
function shouldStripRouteKey(key: string): boolean {
15+
return key.startsWith('_') || key.startsWith('ɵ');
16+
}
17+
18+
function cloneRouteValue(value: unknown): unknown {
19+
if (Array.isArray(value)) {
20+
return value.slice();
21+
}
22+
23+
if (isPlainObject(value)) {
24+
return { ...value };
25+
}
26+
27+
return value;
28+
}
29+
30+
function cloneBootstrapRoute<T extends object>(route: T): T {
31+
const next: AngularBootstrapRouteLike = {};
32+
33+
for (const [key, value] of Object.entries(route as Record<string, unknown>)) {
34+
if (shouldStripRouteKey(key)) {
35+
continue;
36+
}
37+
38+
if (key === 'children' && Array.isArray(value)) {
39+
next.children = cloneRoutesForBootstrap(value);
40+
continue;
41+
}
42+
43+
next[key] = cloneRouteValue(value);
44+
}
45+
46+
return next as T;
47+
}
48+
49+
export function cloneRoutesForBootstrap<T extends object>(routes: T[] | undefined | null): T[] {
50+
if (!Array.isArray(routes)) {
51+
return [];
52+
}
53+
54+
return routes.map((route) => cloneBootstrapRoute(route));
55+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { cloneRoutesForBootstrap } from './hmr-route-bootstrap-core';
2+
3+
describe('cloneRoutesForBootstrap', () => {
4+
it('drops private Angular router cache fields while preserving public route config', () => {
5+
const loadComponent = jest.fn();
6+
const canActivate = [jest.fn()];
7+
const routes = [
8+
{
9+
path: 'signup-landing',
10+
loadComponent,
11+
canActivate,
12+
data: { source: 'signup' },
13+
_loadedComponent: { stale: true },
14+
_loadedInjector: { stale: true },
15+
_loadedRoutes: [{ stale: true }],
16+
_injector: { stale: true },
17+
_loadedNgModuleFactory: { stale: true },
18+
ɵrouterPageId: 'stale',
19+
children: [
20+
{
21+
path: 'child',
22+
loadChildren: jest.fn(),
23+
_loadedComponent: { nested: true },
24+
},
25+
],
26+
},
27+
] as any;
28+
29+
const cloned = cloneRoutesForBootstrap(routes);
30+
31+
expect(cloned).not.toBe(routes);
32+
expect(cloned[0]).not.toBe(routes[0]);
33+
expect(cloned[0].loadComponent).toBe(loadComponent);
34+
expect(cloned[0].canActivate).toEqual(canActivate);
35+
expect(cloned[0].canActivate).not.toBe(canActivate);
36+
expect(cloned[0].data).toEqual({ source: 'signup' });
37+
expect(cloned[0].data).not.toBe(routes[0].data);
38+
expect(cloned[0]._loadedComponent).toBeUndefined();
39+
expect(cloned[0]._loadedInjector).toBeUndefined();
40+
expect(cloned[0]._loadedRoutes).toBeUndefined();
41+
expect(cloned[0]._injector).toBeUndefined();
42+
expect(cloned[0]._loadedNgModuleFactory).toBeUndefined();
43+
expect(cloned[0]['ɵrouterPageId']).toBeUndefined();
44+
expect(cloned[0].children).toEqual([
45+
{
46+
path: 'child',
47+
loadChildren: routes[0].children[0].loadChildren,
48+
},
49+
]);
50+
expect(cloned[0].children).not.toBe(routes[0].children);
51+
expect(cloned[0].children[0]).not.toBe(routes[0].children[0]);
52+
});
53+
54+
it('returns an empty array when routes are missing', () => {
55+
expect(cloneRoutesForBootstrap(undefined)).toEqual([]);
56+
expect(cloneRoutesForBootstrap(null)).toEqual([]);
57+
});
58+
});

packages/angular/src/lib/legacy/router/hmr-route-cache-core.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,34 @@ type AngularHmrRouteLike = {
33
_injector?: unknown;
44
_loadedComponent?: unknown;
55
_loadedInjector?: unknown;
6+
_loadedNgModuleFactory?: unknown;
67
_loadedRoutes?: AngularHmrRouteLike[];
78
};
89

9-
const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedRoutes', '_injector'] as const;
10+
const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedNgModuleFactory', '_loadedRoutes', '_injector'] as const;
11+
12+
function destroyRouteCacheValue(value: unknown): void {
13+
if (!value || typeof value !== 'object') {
14+
return;
15+
}
16+
17+
const destroy = (value as { destroy?: () => void }).destroy;
18+
if (typeof destroy === 'function') {
19+
try {
20+
destroy.call(value);
21+
} catch {}
22+
}
23+
}
1024

1125
function clearRouteCacheField(route: Record<string, unknown>, key: (typeof ROUTE_CACHE_KEYS)[number]): boolean {
1226
if (!Object.prototype.hasOwnProperty.call(route, key) && route[key] === undefined) {
1327
return false;
1428
}
1529

30+
if (key === '_injector' || key === '_loadedInjector' || key === '_loadedNgModuleFactory') {
31+
destroyRouteCacheValue(route[key]);
32+
}
33+
1634
try {
1735
delete route[key];
1836
} catch {

packages/angular/src/lib/legacy/router/hmr-route-cache.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import { clearAngularHmrRouteConfigCaches } from './hmr-route-cache-core';
22

33
describe('Angular HMR route cache clearing', () => {
44
it('clears lazy route caches recursively while preserving public route fields', () => {
5+
const detailsInjectorDestroy = jest.fn();
6+
const surveyInjectorDestroy = jest.fn();
7+
const childInjectorDestroy = jest.fn();
8+
const loadedFactoryDestroy = jest.fn();
59
const grandchild = {
610
path: 'details',
711
_loadedComponent: { name: 'DetailsComponent' },
8-
_loadedInjector: { token: 'details' },
12+
_loadedInjector: { token: 'details', destroy: detailsInjectorDestroy },
913
};
1014
const child = {
1115
path: 'survey',
1216
children: [grandchild],
1317
_loadedComponent: { name: 'SurveyComponent' },
14-
_loadedInjector: { token: 'survey' },
15-
_injector: { token: 'child-injector' },
18+
_loadedInjector: { token: 'survey', destroy: surveyInjectorDestroy },
19+
_loadedNgModuleFactory: { token: 'survey-factory', destroy: loadedFactoryDestroy },
20+
_injector: { token: 'child-injector', destroy: childInjectorDestroy },
1621
};
1722
const route = {
1823
path: 'onboarding-flow',
@@ -33,13 +38,18 @@ describe('Angular HMR route cache clearing', () => {
3338

3439
const cleared = clearAngularHmrRouteConfigCaches([route]);
3540

36-
expect(cleared).toBe(9);
41+
expect(cleared).toBe(10);
3742
expect(route.path).toBe('onboarding-flow');
3843
expect(child.path).toBe('survey');
3944
expect(grandchild.path).toBe('details');
45+
expect(detailsInjectorDestroy).toHaveBeenCalledTimes(1);
46+
expect(surveyInjectorDestroy).toHaveBeenCalledTimes(1);
47+
expect(childInjectorDestroy).toHaveBeenCalledTimes(1);
48+
expect(loadedFactoryDestroy).toHaveBeenCalledTimes(1);
4049
expect((route as any)._loadedRoutes).toBeUndefined();
4150
expect((child as any)._loadedComponent).toBeUndefined();
4251
expect((child as any)._loadedInjector).toBeUndefined();
52+
expect((child as any)._loadedNgModuleFactory).toBeUndefined();
4353
expect((child as any)._injector).toBeUndefined();
4454
expect((grandchild as any)._loadedComponent).toBeUndefined();
4555
expect((grandchild as any)._loadedInjector).toBeUndefined();

0 commit comments

Comments
 (0)