Skip to content

Commit 5b56d73

Browse files
committed
feat: hmr with compiled components and route preservation
1 parent 6628560 commit 5b56d73

9 files changed

Lines changed: 433 additions & 0 deletions

packages/angular/src/lib/application.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as AngularCore from '@angular/core';
22
import { ApplicationRef, EnvironmentProviders, NgModuleRef, NgZone, PlatformRef, Provider } from '@angular/core';
3+
import { Router } from '@angular/router';
34
import {
45
Application,
56
ApplicationEventData,
@@ -15,7 +16,9 @@ import {
1516
import { Observable, Subject } from 'rxjs';
1617
import { filter, map, take } from 'rxjs/operators';
1718
import { AppHostView } from './app-host-view';
19+
import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core';
1820
import { NativeScriptLoadingService } from './loading.service';
21+
import { clearAngularHmrRouteConfigCaches } from './legacy/router/hmr-route-cache-core';
1922
import { APP_ROOT_VIEW, DISABLE_ROOT_VIEW_HANDLING, NATIVESCRIPT_ROOT_MODULE_ID } from './tokens';
2023
import { NativeScriptDebug } from './trace';
2124

@@ -230,6 +233,17 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
230233
let loadingModuleRef: NgModuleRef<K> | ApplicationRef;
231234
let platformRef: PlatformRef = null;
232235
let bootstrapId = -1;
236+
const clearAngularHmrRouteCaches = () => {
237+
try {
238+
const injector = (mainModuleRef as any)?.injector;
239+
const router = injector?.get?.(Router, null);
240+
const cleared = clearAngularHmrRouteConfigCaches(router?.config);
241+
242+
if (cleared > 0) {
243+
console.log('[ng-hmr] cleared Angular route caches before reboot:', cleared);
244+
}
245+
} catch {}
246+
};
233247
const updatePlatformRef = (moduleRef: NgModuleRef<T | K> | ApplicationRef, reason: NgModuleReason) => {
234248
const newPlatformRef = moduleRef.injector.get(PlatformRef);
235249
if (newPlatformRef === platformRef) {
@@ -555,12 +569,20 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
555569
platformRef = null;
556570
};
557571
const disposeLastModules = (reason: NgModuleReason) => {
572+
if (reason === 'hotreload') {
573+
clearAngularHmrRouteCaches();
574+
}
575+
558576
// reset bootstrap ID to make sure any modules bootstrapped after this are discarded
559577
bootstrapId = -1;
560578
destroyRef(loadingModuleRef, 'loading', reason);
561579
loadingModuleRef = null;
562580
destroyRef(mainModuleRef, 'main', reason);
563581
mainModuleRef = null;
582+
583+
if (reason === 'hotreload') {
584+
resetAngularHmrCompiledComponents(AngularCore as any);
585+
}
564586
};
565587
const launchCallback = profile('@nativescript/angular/platform-common.launchCallback', (args: LaunchEventData) => {
566588
launchEventDone = false;
@@ -615,6 +637,9 @@ export function runNativeScriptAngularApp<T, K>(options: AppRunOptions<T, K>) {
615637
global['__reboot_ng_modules__'] = (shouldDisposePlatform: boolean = false) => {
616638
console.log('[ng-hmr] __reboot_ng_modules__ called, shouldDisposePlatform:', shouldDisposePlatform);
617639
console.log('[ng-hmr] current bootstrapId:', bootstrapId, 'mainModuleRef:', !!mainModuleRef);
640+
try {
641+
global['__NS_CAPTURE_ANGULAR_HMR_ROUTE__']?.();
642+
} catch {}
618643
disposeLastModules('hotreload');
619644
console.log('[ng-hmr] after disposeLastModules, bootstrapId:', bootstrapId);
620645
if (shouldDisposePlatform) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { resetAngularHmrCompiledComponents } from './hmr-compiled-components-core';
2+
3+
describe('Angular HMR compiled component reset', () => {
4+
it('calls Angular internal compiled-component reset when available', () => {
5+
const core = {
6+
ɵresetCompiledComponents: jest.fn(),
7+
};
8+
9+
expect(resetAngularHmrCompiledComponents(core)).toBe(true);
10+
expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1);
11+
});
12+
13+
it('returns false when Angular core does not expose the reset hook', () => {
14+
expect(resetAngularHmrCompiledComponents({})).toBe(false);
15+
});
16+
17+
it('swallows reset failures so HMR disposal can continue', () => {
18+
const core = {
19+
ɵresetCompiledComponents: jest.fn(() => {
20+
throw new Error('boom');
21+
}),
22+
};
23+
24+
expect(resetAngularHmrCompiledComponents(core)).toBe(false);
25+
expect(core.ɵresetCompiledComponents).toHaveBeenCalledTimes(1);
26+
});
27+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
type AngularCoreWithCompiledComponentReset = {
2+
ɵresetCompiledComponents?: () => void;
3+
};
4+
5+
export function resetAngularHmrCompiledComponents(
6+
core: AngularCoreWithCompiledComponentReset | null | undefined,
7+
): boolean {
8+
const resetCompiledComponents = core?.ɵresetCompiledComponents;
9+
if (typeof resetCompiledComponents !== 'function') {
10+
return false;
11+
}
12+
13+
try {
14+
resetCompiledComponents.call(core);
15+
return true;
16+
} catch {
17+
return false;
18+
}
19+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
type AngularHmrRouteLike = {
2+
children?: AngularHmrRouteLike[];
3+
_injector?: unknown;
4+
_loadedComponent?: unknown;
5+
_loadedInjector?: unknown;
6+
_loadedRoutes?: AngularHmrRouteLike[];
7+
};
8+
9+
const ROUTE_CACHE_KEYS = ['_loadedComponent', '_loadedInjector', '_loadedRoutes', '_injector'] as const;
10+
11+
function clearRouteCacheField(route: Record<string, unknown>, key: (typeof ROUTE_CACHE_KEYS)[number]): boolean {
12+
if (!Object.prototype.hasOwnProperty.call(route, key) && route[key] === undefined) {
13+
return false;
14+
}
15+
16+
try {
17+
delete route[key];
18+
} catch {
19+
try {
20+
route[key] = undefined;
21+
} catch {}
22+
}
23+
24+
return true;
25+
}
26+
27+
export function clearAngularHmrRouteConfigCaches(routes: AngularHmrRouteLike[] | undefined | null): number {
28+
const seen = new Set<AngularHmrRouteLike>();
29+
let cleared = 0;
30+
31+
const visitRoute = (route: AngularHmrRouteLike | undefined | null): void => {
32+
if (!route || seen.has(route)) {
33+
return;
34+
}
35+
36+
seen.add(route);
37+
38+
const childRoutes = Array.isArray(route.children) ? route.children : [];
39+
const loadedRoutes = Array.isArray(route._loadedRoutes) ? route._loadedRoutes : [];
40+
41+
for (const childRoute of childRoutes) {
42+
visitRoute(childRoute);
43+
}
44+
45+
for (const loadedRoute of loadedRoutes) {
46+
visitRoute(loadedRoute);
47+
}
48+
49+
for (const key of ROUTE_CACHE_KEYS) {
50+
if (clearRouteCacheField(route as Record<string, unknown>, key)) {
51+
cleared += 1;
52+
}
53+
}
54+
};
55+
56+
for (const route of Array.isArray(routes) ? routes : []) {
57+
visitRoute(route);
58+
}
59+
60+
return cleared;
61+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { clearAngularHmrRouteConfigCaches } from './hmr-route-cache-core';
2+
3+
describe('Angular HMR route cache clearing', () => {
4+
it('clears lazy route caches recursively while preserving public route fields', () => {
5+
const grandchild = {
6+
path: 'details',
7+
_loadedComponent: { name: 'DetailsComponent' },
8+
_loadedInjector: { token: 'details' },
9+
};
10+
const child = {
11+
path: 'survey',
12+
children: [grandchild],
13+
_loadedComponent: { name: 'SurveyComponent' },
14+
_loadedInjector: { token: 'survey' },
15+
_injector: { token: 'child-injector' },
16+
};
17+
const route = {
18+
path: 'onboarding-flow',
19+
children: [child],
20+
_loadedRoutes: [
21+
{
22+
path: 'lazy',
23+
_loadedComponent: { name: 'LazyComponent' },
24+
_loadedRoutes: [
25+
{
26+
path: 'nested',
27+
_injector: { token: 'nested-injector' },
28+
},
29+
],
30+
},
31+
],
32+
};
33+
34+
const cleared = clearAngularHmrRouteConfigCaches([route]);
35+
36+
expect(cleared).toBe(9);
37+
expect(route.path).toBe('onboarding-flow');
38+
expect(child.path).toBe('survey');
39+
expect(grandchild.path).toBe('details');
40+
expect((route as any)._loadedRoutes).toBeUndefined();
41+
expect((child as any)._loadedComponent).toBeUndefined();
42+
expect((child as any)._loadedInjector).toBeUndefined();
43+
expect((child as any)._injector).toBeUndefined();
44+
expect((grandchild as any)._loadedComponent).toBeUndefined();
45+
expect((grandchild as any)._loadedInjector).toBeUndefined();
46+
});
47+
48+
it('does not loop forever when route graphs reuse the same child object', () => {
49+
const shared = {
50+
path: 'shared',
51+
_loadedComponent: { name: 'SharedComponent' },
52+
};
53+
const routes = [
54+
{
55+
path: 'a',
56+
children: [shared],
57+
},
58+
{
59+
path: 'b',
60+
_loadedRoutes: [shared],
61+
},
62+
];
63+
64+
const cleared = clearAngularHmrRouteConfigCaches(routes as any);
65+
66+
expect(cleared).toBe(2);
67+
expect((shared as any)._loadedComponent).toBeUndefined();
68+
});
69+
});
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
type AngularHmrRouteState = {
2+
url: string;
3+
source: string;
4+
timestamp: number;
5+
};
6+
7+
const CURRENT_ROUTE_KEY = '__NS_ANGULAR_HMR_CURRENT_ROUTE__';
8+
const PENDING_START_PATH_KEY = '__NS_ANGULAR_HMR_PENDING_START_PATH__';
9+
const CAPTURE_ROUTE_KEY = '__NS_CAPTURE_ANGULAR_HMR_ROUTE__';
10+
11+
function getGlobalState(): any {
12+
return globalThis as any;
13+
}
14+
15+
export function normalizeAngularHmrRouteUrl(value: unknown): string | null {
16+
if (typeof value !== 'string') {
17+
return null;
18+
}
19+
20+
const trimmed = value.trim();
21+
if (!trimmed) {
22+
return null;
23+
}
24+
25+
if (trimmed.startsWith('/')) {
26+
return trimmed;
27+
}
28+
29+
if (trimmed.startsWith('?') || trimmed.startsWith('#')) {
30+
return `/${trimmed}`;
31+
}
32+
33+
return `/${trimmed.replace(/^\/+/, '')}`;
34+
}
35+
36+
export function writeAngularHmrRouteState(
37+
value: unknown,
38+
options: {
39+
pending?: boolean;
40+
source: string;
41+
},
42+
): string | null {
43+
const url = normalizeAngularHmrRouteUrl(value);
44+
if (!url) {
45+
return null;
46+
}
47+
48+
const state: AngularHmrRouteState = {
49+
url,
50+
source: options.source,
51+
timestamp: Date.now(),
52+
};
53+
54+
const g = getGlobalState();
55+
g[CURRENT_ROUTE_KEY] = state;
56+
if (options.pending) {
57+
g[PENDING_START_PATH_KEY] = state;
58+
}
59+
60+
return url;
61+
}
62+
63+
export function captureAngularHmrPendingStartPath(value: unknown, source = 'hmr-reboot'): string | null {
64+
return writeAngularHmrRouteState(value, { pending: true, source });
65+
}
66+
67+
export function readAngularHmrPendingStartPath(): string {
68+
const g = getGlobalState();
69+
return normalizeAngularHmrRouteUrl(g[PENDING_START_PATH_KEY]?.url ?? g[PENDING_START_PATH_KEY]) || '';
70+
}
71+
72+
export function invokeAngularHmrRouteCapture(): string | null {
73+
const g = getGlobalState();
74+
const capture = g[CAPTURE_ROUTE_KEY];
75+
if (typeof capture === 'function') {
76+
try {
77+
return capture();
78+
} catch {
79+
// Fall back to the last known router url when the active capture hook fails.
80+
}
81+
}
82+
83+
return captureAngularHmrPendingStartPath(g[CURRENT_ROUTE_KEY]?.url ?? g[CURRENT_ROUTE_KEY], 'hmr-fallback');
84+
}
85+
86+
export function installAngularHmrRouteCaptureHook(capture: () => string | null): () => void {
87+
const g = getGlobalState();
88+
g[CAPTURE_ROUTE_KEY] = capture;
89+
90+
return () => {
91+
if (g[CAPTURE_ROUTE_KEY] === capture) {
92+
delete g[CAPTURE_ROUTE_KEY];
93+
}
94+
};
95+
}

0 commit comments

Comments
 (0)