Skip to content

Commit feb8279

Browse files
committed
fix: initialize RouterHistoryStore on app initialization
1 parent 3b30d07 commit feb8279

File tree

2 files changed

+113
-47
lines changed

2 files changed

+113
-47
lines changed

packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing';
44
import { By } from '@angular/platform-browser';
55
import { Router, RouterLink, RouterOutlet } from '@angular/router';
66
import { RouterTestingModule } from '@angular/router/testing';
7+
import { firstValueFrom } from 'rxjs';
78
import {
89
provideRouterHistoryStore,
910
RouterHistoryStore,
@@ -21,13 +22,13 @@ function createTestComponent(name: string, selector: string) {
2122
selector: 'ngw-test-app',
2223
imports: [AsyncPipe, NgIf, RouterLink, RouterOutlet],
2324
template: `
24-
<!-- <a
25+
<a
2526
id="back-link"
2627
*ngIf="routerHistory.previousUrl$ | async as previousUrl"
27-
(click)="onBack()"
28-
>Back</a
29-
> -->
30-
<a id="back-link" (click)="onBack()">Back</a>
28+
[href]="previousUrl"
29+
(click)="onBack($event)"
30+
>&lt; Back</a
31+
>
3132
3233
<a id="home-link" routerLink="/">Home</a>
3334
<a id="about-link" routerLink="about">About</a>
@@ -42,7 +43,8 @@ class TestAppComponent {
4243

4344
protected routerHistory = inject(RouterHistoryStore);
4445

45-
onBack() {
46+
onBack(event: MouseEvent) {
47+
event.preventDefault();
4648
this.#location.back();
4749
}
4850
}
@@ -101,14 +103,6 @@ describe(RouterHistoryStore.name, () => {
101103
expect.assertions(2);
102104

103105
const { click, routerHistory } = await setup();
104-
let currentUrl: string | undefined;
105-
routerHistory.currentUrl$.subscribe((url) => {
106-
currentUrl = url;
107-
});
108-
let previousUrl: string | null | undefined;
109-
routerHistory.previousUrl$.subscribe((url) => {
110-
previousUrl = url;
111-
});
112106

113107
// At Home
114108
await click('#about-link');
@@ -118,22 +112,14 @@ describe(RouterHistoryStore.name, () => {
118112
await click('#products-link');
119113
// At Products
120114

121-
expect(currentUrl).toBe('/products');
122-
expect(previousUrl).toBe('/company');
115+
expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products');
116+
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/company');
123117
});
124118

125119
it('the URLs behave like the History API when navigating back', async () => {
126120
expect.assertions(2);
127121

128122
const { click, routerHistory } = await setup();
129-
let currentUrl: string | undefined;
130-
routerHistory.currentUrl$.subscribe((url) => {
131-
currentUrl = url;
132-
});
133-
let previousUrl: string | null | undefined;
134-
routerHistory.previousUrl$.subscribe((url) => {
135-
previousUrl = url;
136-
});
137123

138124
// At Home
139125
await click('#about-link');
@@ -143,22 +129,14 @@ describe(RouterHistoryStore.name, () => {
143129
await click('#back-link');
144130
// At About
145131

146-
expect(currentUrl).toBe('/about');
147-
expect(previousUrl).toBe('/home');
132+
expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about');
133+
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home');
148134
});
149135

150136
it('the URLs behave like the History API when navigating back then using links', async () => {
151137
expect.assertions(2);
152138

153139
const { click, routerHistory } = await setup();
154-
let currentUrl: string | undefined;
155-
routerHistory.currentUrl$.subscribe((url) => {
156-
currentUrl = url;
157-
});
158-
let previousUrl: string | null | undefined;
159-
routerHistory.previousUrl$.subscribe((url) => {
160-
previousUrl = url;
161-
});
162140

163141
// At Home
164142
await click('#about-link');
@@ -170,7 +148,7 @@ describe(RouterHistoryStore.name, () => {
170148
await click('#products-link');
171149
// At Products
172150

173-
expect(currentUrl).toBe('/products');
174-
expect(previousUrl).toBe('/about');
151+
expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products');
152+
expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about');
175153
});
176154
});

packages/router-component-store/src/lib/router-history-store/router-history.store.ts

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
1-
import { inject, Injectable, Provider } from '@angular/core';
1+
import {
2+
APP_INITIALIZER,
3+
FactoryProvider,
4+
inject,
5+
Injectable,
6+
Provider,
7+
} from '@angular/core';
28
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
39
import { ComponentStore, provideComponentStore } from '@ngrx/component-store';
410
import { filter, Observable } from 'rxjs';
511

612
interface RouterHistoryState {
13+
/**
14+
* The history of all navigations.
15+
*/
716
readonly history: NavigationHistory;
817
}
918

@@ -12,8 +21,17 @@ type NavigationHistory = Record<number, NavigationSequence>;
1221
type NavigationSequence = PendingNavigation | CompleteNavigation;
1322
type PendingNavigation = readonly [NavigationStart];
1423

24+
/**
25+
* Provide and initialize the `RouterHistoryStore`.
26+
*
27+
* @remarks
28+
* Must be provided by the root injector to capture all navigation events.
29+
*/
1530
export function provideRouterHistoryStore(): Provider[] {
16-
return [provideComponentStore(RouterHistoryStore)];
31+
return [
32+
provideComponentStore(RouterHistoryStore),
33+
routerHistoryStoreInitializer,
34+
];
1735
}
1836

1937
// TODO(@LayZeeDK): Handle `NavigationCancel` and `NavigationError` events
@@ -49,26 +67,45 @@ export function provideRouterHistoryStore(): Provider[] {
4967
export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
5068
#router = inject(Router);
5169

70+
/**
71+
* The history of all navigations.
72+
*/
5273
#history$ = this.select((state) => state.history).pipe(
5374
filter((history) => Object.keys(history).length > 0)
5475
);
76+
/**
77+
* All `NavigationEnd` events.
78+
*/
5579
#navigationEnd$: Observable<NavigationEnd> = this.#router.events.pipe(
5680
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
5781
);
82+
/**
83+
* All `NavigationStart` events.
84+
*/
5885
#navigationStart$: Observable<NavigationStart> = this.#router.events.pipe(
5986
filter(
6087
(event): event is NavigationStart => event instanceof NavigationStart
6188
)
6289
);
6390

64-
#maxCompletedNavigationId$ = this.select(this.#history$, (history) =>
65-
Number(
66-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
67-
Object.entries(history)
68-
.reverse()
69-
.find(([, navigation]) => navigation.length === 2)![0]
70-
)
91+
/**
92+
* The navigation ID of the most recent completed navigation.
93+
*/
94+
#maxCompletedNavigationId$ = this.select(
95+
this.#history$.pipe(filter((history) => (history[1] ?? []).length > 1)),
96+
(history) =>
97+
Number(
98+
// This callback is only triggered when at least one navigation has
99+
// completed
100+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
101+
Object.entries(history)
102+
.reverse()
103+
.find(([, navigation]) => navigation.length === 2)![0]
104+
)
71105
);
106+
/**
107+
* The most recent completed navigation.
108+
*/
72109
#latestCompletedNavigation$ = this.select(
73110
this.#maxCompletedNavigationId$,
74111
this.#history$,
@@ -79,10 +116,19 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
79116
}
80117
);
81118

119+
/**
120+
* The current URL.
121+
*/
82122
currentUrl$: Observable<string> = this.select(
83123
this.#latestCompletedNavigation$,
84124
([, end]) => end.urlAfterRedirects
85125
);
126+
/**
127+
* The previous URL when taking `popstate` events into account.
128+
*
129+
* `undefined` is emitted when the current navigation is the first in the
130+
* navigation history.
131+
*/
86132
previousUrl$: Observable<string | undefined> = this.select(
87133
this.#history$,
88134
this.#maxCompletedNavigationId$,
@@ -95,6 +141,11 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
95141
maxCompletedNavigationId,
96142
history
97143
);
144+
145+
if (completedNavigationSourceStart.id === 1) {
146+
return undefined;
147+
}
148+
98149
const previousNavigationId = completedNavigationSourceStart.id - 1;
99150
const [, previousNavigationSourceEnd] = this.#getNavigationSource(
100151
previousNavigationId,
@@ -115,6 +166,9 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
115166
this.#addNavigationEnd(this.#navigationEnd$);
116167
}
117168

169+
/**
170+
* Add a `NavigationEnd` event to the navigation history.
171+
*/
118172
#addNavigationEnd = this.updater<NavigationEnd>(
119173
(state, event): RouterHistoryState => ({
120174
...state,
@@ -125,6 +179,9 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
125179
})
126180
);
127181

182+
/**
183+
* Add a `NavigationStart` event to the navigation history.
184+
*/
128185
#addNavigationStart = this.updater<NavigationStart>(
129186
(state, event): RouterHistoryState => ({
130187
...state,
@@ -135,6 +192,16 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
135192
})
136193
);
137194

195+
/**
196+
* Search the specified navigation history to find the source of the
197+
* specified navigation event.
198+
*
199+
* This takes `popstate` navigation events into account.
200+
*
201+
* @param navigationId The ID of the navigation to trace.
202+
* @param history The navigation history to search.
203+
* @returns The source navigation.
204+
*/
138205
#getNavigationSource(
139206
navigationId: number,
140207
history: NavigationHistory
@@ -144,16 +211,37 @@ export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
144211
while (navigation[0].navigationTrigger === 'popstate') {
145212
navigation =
146213
history[
214+
// Navigation events triggered by `popstate` always have a
215+
// `restoredState`
147216
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
148217
navigation[0].restoredState!.navigationId
149218
];
150-
navigationId = navigation[0].id;
151219
}
152220

153221
return navigation as CompleteNavigation;
154222
}
155223
}
156224

157-
export const initialState: RouterHistoryState = {
225+
/**
226+
* The initial internal state of the `RouterHistoryStore`.
227+
*/
228+
const initialState: RouterHistoryState = {
158229
history: [],
159230
};
231+
232+
const initializeRouterHistoryStoreFactory =
233+
// Inject the RouterHistoryStore to eagerly initialize it.
234+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
235+
(_initializedRouterHistoryStore: RouterHistoryStore) => (): void => undefined;
236+
/**
237+
* Eagerly initialize the `RouterHistoryStore` to subscribe to all relevant
238+
* router navigation events.
239+
*/
240+
const routerHistoryStoreInitializer: FactoryProvider = {
241+
provide: APP_INITIALIZER,
242+
multi: true,
243+
deps: [RouterHistoryStore],
244+
useFactory:
245+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
246+
initializeRouterHistoryStoreFactory,
247+
};

0 commit comments

Comments
 (0)