Skip to content

Commit cef939e

Browse files
committed
fix: improve route handling on hmr updates
1 parent 95822ce commit cef939e

4 files changed

Lines changed: 175 additions & 2 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,27 @@ export function clearAngularHmrRouteHistory(): void {
222222
delete g[HISTORY_KEY];
223223
}
224224

225+
/**
226+
* Replace the entire live back-stack mirror with a single URL. Mirrors
227+
* NativeScript's `clearHistory: true` navigation option, which collapses
228+
* the native page stack down to the destination — without this, the HMR
229+
* snapshot would still carry every URL the user passed through before
230+
* the reset (e.g. login screens that auth-gates now hide), and the next
231+
* reboot would walk through every one of them as a forward navigation.
232+
*
233+
* An empty / unparseable `value` clears the mirror entirely.
234+
*/
235+
export function resetAngularHmrRouteHistoryToUrl(value: unknown): string | null {
236+
const url = normalizeAngularHmrRouteUrl(value);
237+
if (!url) {
238+
writeHistoryArray(HISTORY_KEY, []);
239+
return null;
240+
}
241+
242+
writeHistoryArray(HISTORY_KEY, [url]);
243+
return url;
244+
}
245+
225246
/**
226247
* Snapshot the live back-stack mirror under the pending-history slot so the
227248
* next bootstrap can read it. Called from the HMR capture hook.

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

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,22 +46,37 @@ interface RouterEvent {
4646
interface RouterMock {
4747
events: Subject<RouterEvent>;
4848
url: string;
49+
// Mirrors `Router.getCurrentNavigation()` enough that the tracker can read
50+
// `extras.clearHistory` off it — the same hook NS's page-router-outlet uses
51+
// to enable `clearHistory: true` end-to-end.
52+
currentNavigation: { extras?: { clearHistory?: boolean } } | null;
53+
getCurrentNavigation(): { extras?: { clearHistory?: boolean } } | null;
4954
emitNavigationEnd(url: string): void;
5055
emitNavigationStart(
5156
url: string,
5257
options?: {
5358
trigger?: 'imperative' | 'popstate' | 'hashchange';
5459
restoredState?: { navigationId: number } | null;
60+
clearHistory?: boolean;
5561
},
5662
): void;
5763
}
5864

5965
function createRouterMock(initialUrl: string): RouterMock {
6066
const events = new Subject<RouterEvent>();
61-
return {
67+
const router: RouterMock = {
6268
events,
6369
url: initialUrl,
70+
currentNavigation: null,
71+
getCurrentNavigation() {
72+
return this.currentNavigation;
73+
},
6474
emitNavigationStart(url, options) {
75+
// Mirror Angular: `getCurrentNavigation()` returns the active navigation
76+
// between NavigationStart and NavigationEnd, then is cleared.
77+
this.currentNavigation = {
78+
extras: options?.clearHistory ? { clearHistory: true } : {},
79+
};
6580
events.next(
6681
new MockNavigationStart(
6782
1,
@@ -74,8 +89,10 @@ function createRouterMock(initialUrl: string): RouterMock {
7489
emitNavigationEnd(url) {
7590
this.url = url;
7691
events.next(new MockNavigationEnd(1, url, url) as unknown as RouterEvent);
92+
this.currentNavigation = null;
7793
},
7894
};
95+
return router;
7996
}
8097

8198
describe('NativeScriptAngularHmrRouteTracker', () => {
@@ -170,6 +187,74 @@ describe('NativeScriptAngularHmrRouteTracker', () => {
170187
});
171188
});
172189

190+
describe('clearHistory navigation extra', () => {
191+
it('collapses the live mirror to the destination URL when clearHistory is set', () => {
192+
// Reproduces the canonical HeyKiddo auth flow: user passes through
193+
// /signup-landing, /login on the way to a clearHistory navigation
194+
// that drops the iOS back-stack down to /talk. Without mirroring
195+
// that on the HMR side, a subsequent .ts edit replays NavigationEnd
196+
// for every URL the user passed through — including login pages
197+
// that the auth-gate now redirects away from.
198+
const router = createRouterMock('/');
199+
200+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
201+
const tracker = new NativeScriptAngularHmrRouteTracker(router as never);
202+
203+
router.emitNavigationStart('/signup-landing');
204+
router.emitNavigationEnd('/signup-landing');
205+
router.emitNavigationStart('/login');
206+
router.emitNavigationEnd('/login');
207+
208+
expect(readAngularHmrRouteHistory()).toEqual(['/signup-landing', '/login']);
209+
210+
router.emitNavigationStart('/talk/(todayTab:today)', { clearHistory: true });
211+
router.emitNavigationEnd('/talk/(todayTab:today)');
212+
213+
expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']);
214+
});
215+
216+
it('keeps subsequent non-clearHistory navigations on top of the destination after a reset', () => {
217+
// After a clearHistory reset, normal forward navigation (e.g. tab
218+
// switches) should still grow the mirror so the back-stack is
219+
// restored across HMR cycles for the post-reset session.
220+
const router = createRouterMock('/');
221+
222+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
223+
const tracker = new NativeScriptAngularHmrRouteTracker(router as never);
224+
225+
router.emitNavigationStart('/login');
226+
router.emitNavigationEnd('/login');
227+
router.emitNavigationStart('/talk/(todayTab:today)', { clearHistory: true });
228+
router.emitNavigationEnd('/talk/(todayTab:today)');
229+
router.emitNavigationStart('/talk/(progressTab:progress//todayTab:today)');
230+
router.emitNavigationEnd('/talk/(progressTab:progress//todayTab:today)');
231+
232+
expect(readAngularHmrRouteHistory()).toEqual([
233+
'/talk/(todayTab:today)',
234+
'/talk/(progressTab:progress//todayTab:today)',
235+
]);
236+
});
237+
238+
it('does not throw when the router mock omits getCurrentNavigation', () => {
239+
// Defensive: the tracker should fall back to the legacy push path on
240+
// routers that don't expose `getCurrentNavigation()`. This protects
241+
// older Angular versions, edge-case mocks, and hardened proxy wrappers
242+
// that strip non-public methods.
243+
const router = createRouterMock('/');
244+
// Force a router shape without the API; the tracker must treat it
245+
// as "no clearHistory".
246+
(router as { getCurrentNavigation?: unknown }).getCurrentNavigation = undefined;
247+
248+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
249+
const tracker = new NativeScriptAngularHmrRouteTracker(router as never);
250+
251+
router.emitNavigationStart('/login', { clearHistory: true });
252+
router.emitNavigationEnd('/login');
253+
254+
expect(readAngularHmrRouteHistory()).toEqual(['/login']);
255+
});
256+
});
257+
173258
describe('clear-after-snapshot integration', () => {
174259
it('a snapshot during HMR reboot leaves the live mirror empty for the next bootstrap to rebuild', () => {
175260
// Cycle 1: real boot, walk forward to /profile.

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
readAngularHmrPendingStartPath,
1717
readAngularHmrRouteHistory,
1818
replaceAngularHmrRouteHistoryTop,
19+
resetAngularHmrRouteHistoryToUrl,
1920
snapshotAngularHmrRouteHistory,
2021
writeAngularHmrRouteState,
2122
} from './hmr-route-state-core';
@@ -101,6 +102,26 @@ describe('Angular HMR route state', () => {
101102
expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)', '/profile?tab=goals']);
102103
});
103104

105+
it('collapses the live mirror to a single URL on a clearHistory navigation', () => {
106+
// Mirrors NativeScript's `clearHistory: true` extra: the user-visible
107+
// back-stack is reset to just the destination, so the HMR mirror
108+
// must follow suit.
109+
pushAngularHmrRouteHistoryEntry('/');
110+
pushAngularHmrRouteHistoryEntry('/signup-landing');
111+
pushAngularHmrRouteHistoryEntry('/login');
112+
113+
expect(resetAngularHmrRouteHistoryToUrl('/talk/(todayTab:today)')).toBe('/talk/(todayTab:today)');
114+
expect(readAngularHmrRouteHistory()).toEqual(['/talk/(todayTab:today)']);
115+
});
116+
117+
it('clears the live mirror entirely when reset is called with an unparseable URL', () => {
118+
pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)');
119+
pushAngularHmrRouteHistoryEntry('/profile');
120+
121+
expect(resetAngularHmrRouteHistoryToUrl(undefined)).toBeNull();
122+
expect(readAngularHmrRouteHistory()).toEqual([]);
123+
});
124+
104125
it('snapshots the live mirror into the pending slot for the next bootstrap', () => {
105126
pushAngularHmrRouteHistoryEntry('/talk/(todayTab:today)');
106127
pushAngularHmrRouteHistoryEntry('/profile');

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
pushAngularHmrRouteHistoryEntry,
1010
readAngularHmrPendingStartPath,
1111
replaceAngularHmrRouteHistoryTop,
12+
resetAngularHmrRouteHistoryToUrl,
1213
snapshotAngularHmrRouteHistory,
1314
writeAngularHmrRouteState,
1415
} from './hmr-route-state-core';
@@ -29,10 +30,41 @@ export {
2930
readAngularHmrPendingRouteHistory,
3031
readAngularHmrRouteHistory,
3132
replaceAngularHmrRouteHistoryTop,
33+
resetAngularHmrRouteHistoryToUrl,
3234
snapshotAngularHmrRouteHistory,
3335
} from './hmr-route-state-core';
3436
export { readAngularHmrPendingStartPath } from './hmr-route-state-core';
3537

38+
/**
39+
* Read NativeScript's `clearHistory: true` navigation extra off the active
40+
* Angular navigation. Defensive against test mocks and bare `Router`-like
41+
* shapes that don't expose `getCurrentNavigation` (e.g. earlier Angular
42+
* versions and the unit-test mocks in `hmr-route-state-tracker.spec.ts`).
43+
*
44+
* `clearHistory` is the NativeScript-only signal that
45+
* `NSLocationStrategy._beginPageNavigation` uses to collapse the native page
46+
* stack down to the destination. We mirror that on the HMR side so a
47+
* subsequent reboot doesn't replay URLs the user can no longer reach (the
48+
* canonical example: `/`, `/signup-landing`, `/login` after the auth flow
49+
* navigated to `/talk/(todayTab:today)` with `clearHistory: true`).
50+
*/
51+
function readClearHistoryFromRouter(router: Router): boolean {
52+
const getCurrentNavigation = (router as { getCurrentNavigation?: () => unknown }).getCurrentNavigation;
53+
if (typeof getCurrentNavigation !== 'function') {
54+
return false;
55+
}
56+
57+
let navigation: unknown;
58+
try {
59+
navigation = getCurrentNavigation.call(router);
60+
} catch {
61+
return false;
62+
}
63+
64+
const extras = (navigation as { extras?: { clearHistory?: unknown } } | null | undefined)?.extras;
65+
return !!extras?.clearHistory;
66+
}
67+
3668
@Injectable()
3769
export class NativeScriptAngularHmrRouteTracker implements OnDestroy {
3870
private subscription?: Subscription;
@@ -42,6 +74,12 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy {
4274
// `NavigationEnd` we can pop our mirror instead of pushing a duplicate entry.
4375
private currentNavigationIsPopstate = false;
4476
private currentNavigationReplaceUrl = false;
77+
// Tracks whether the active navigation was started with NativeScript's
78+
// `clearHistory: true` extra (read off `router.getCurrentNavigation()` at
79+
// `NavigationStart`). When set, the matching `NavigationEnd` collapses the
80+
// mirror down to just the destination URL — see
81+
// `resetAngularHmrRouteHistoryToUrl` for the rationale.
82+
private currentNavigationClearsHistory = false;
4583

4684
constructor(private readonly router: Router) {
4785
if (!isAngularHmrEnabled()) {
@@ -54,6 +92,7 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy {
5492
if (event instanceof NavigationStart) {
5593
this.currentNavigationIsPopstate = event.navigationTrigger === 'popstate';
5694
this.currentNavigationReplaceUrl = !!event.restoredState;
95+
this.currentNavigationClearsHistory = readClearHistoryFromRouter(this.router);
5796
return;
5897
}
5998

@@ -63,7 +102,13 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy {
63102
source: 'navigation-end',
64103
});
65104

66-
if (this.currentNavigationIsPopstate) {
105+
if (this.currentNavigationClearsHistory) {
106+
// NativeScript collapsed the native page stack to this single
107+
// destination. Mirror that on the HMR side so a future reboot
108+
// replays only what the user can still navigate back through —
109+
// not every URL they passed through before the reset.
110+
resetAngularHmrRouteHistoryToUrl(url);
111+
} else if (this.currentNavigationIsPopstate) {
67112
// The user (or NSLocationStrategy.back()) walked the back-stack down
68113
// by one page; mirror that by dropping the top of our snapshot so a
69114
// subsequent HMR reboot doesn't carry the popped page back into view.
@@ -76,6 +121,7 @@ export class NativeScriptAngularHmrRouteTracker implements OnDestroy {
76121

77122
this.currentNavigationIsPopstate = false;
78123
this.currentNavigationReplaceUrl = false;
124+
this.currentNavigationClearsHistory = false;
79125
}
80126
});
81127
}

0 commit comments

Comments
 (0)