Skip to content

Commit ff3a259

Browse files
committed
feat: add RouterHistoryStore
1 parent 60a918a commit ff3a259

File tree

1 file changed

+183
-0
lines changed

1 file changed

+183
-0
lines changed
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { inject, Injectable, Provider } from '@angular/core';
2+
import {
3+
Navigation,
4+
NavigationEnd,
5+
NavigationStart,
6+
Router,
7+
} from '@angular/router';
8+
import { ComponentStore, provideComponentStore } from '@ngrx/component-store';
9+
import { concatMap, filter, Observable, take } from 'rxjs';
10+
11+
interface RouterHistoryRecord {
12+
readonly id: number;
13+
readonly url: string;
14+
}
15+
16+
interface RouterHistoryState {
17+
readonly currentIndex: number;
18+
readonly event?: NavigationStart | NavigationEnd;
19+
readonly history: readonly RouterHistoryRecord[];
20+
readonly id: number;
21+
readonly idToRestore?: number;
22+
readonly trigger?: Navigation['trigger'];
23+
}
24+
25+
export function provideRouterHistoryStore(): Provider[] {
26+
return [provideComponentStore(RouterHistoryStore)];
27+
}
28+
29+
@Injectable()
30+
export class RouterHistoryStore extends ComponentStore<RouterHistoryState> {
31+
#router = inject(Router);
32+
33+
#currentIndex$: Observable<number> = this.select(
34+
(state) => state.currentIndex
35+
);
36+
#history$: Observable<readonly RouterHistoryRecord[]> = this.select(
37+
(state) => state.history
38+
);
39+
#navigationEnd$: Observable<NavigationEnd> = this.#router.events.pipe(
40+
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
41+
);
42+
#navigationStart$: Observable<NavigationStart> = this.#router.events.pipe(
43+
filter(
44+
(event): event is NavigationStart => event instanceof NavigationStart
45+
)
46+
);
47+
#imperativeNavigationEnd$: Observable<NavigationEnd> =
48+
this.#navigationStart$.pipe(
49+
filter((event) => event.navigationTrigger === 'imperative'),
50+
concatMap(() => this.#navigationEnd$.pipe(take(1)))
51+
);
52+
#popstateNavigationEnd$: Observable<NavigationEnd> =
53+
this.#navigationStart$.pipe(
54+
filter((event) => event.navigationTrigger === 'popstate'),
55+
concatMap(() => this.#navigationEnd$.pipe(take(1)))
56+
);
57+
58+
currentUrl$: Observable<string> = this.select(
59+
this.#navigationEnd$.pipe(
60+
concatMap(() =>
61+
this.select(
62+
this.#currentIndex$,
63+
this.#history$,
64+
(currentIndex, history) => [currentIndex, history] as const
65+
)
66+
)
67+
),
68+
([currentIndex, history]) => history[currentIndex].url,
69+
{
70+
debounce: true,
71+
}
72+
);
73+
previousUrl$: Observable<string | null> = this.select(
74+
this.#navigationEnd$.pipe(
75+
concatMap(() =>
76+
this.select(
77+
this.#currentIndex$,
78+
this.#history$,
79+
(currentIndex, history) => [currentIndex, history] as const
80+
)
81+
)
82+
),
83+
([currentIndex, history]) => history[currentIndex - 1]?.url ?? null,
84+
{
85+
debounce: true,
86+
}
87+
);
88+
89+
constructor() {
90+
super(initialState);
91+
92+
this.#updateRouterHistoryOnNavigationStart(this.#navigationStart$);
93+
this.#updateRouterHistoryOnImperativeNavigationEnd(
94+
this.#imperativeNavigationEnd$
95+
);
96+
this.#updateRouterHistoryOnPopstateNavigationEnd(
97+
this.#popstateNavigationEnd$
98+
);
99+
}
100+
101+
/**
102+
* Update router history on imperative navigation end (`Router#navigate`,
103+
* `Router#navigateByUrl`, or `RouterLink` click).
104+
*/
105+
#updateRouterHistoryOnImperativeNavigationEnd = this.updater<NavigationEnd>(
106+
(state, event): RouterHistoryState => {
107+
let currentIndex = state.currentIndex;
108+
let history = state.history;
109+
// remove all events in history that come after the current index
110+
history = [
111+
...history.slice(0, currentIndex + 1),
112+
// add the new event to the end of the history
113+
{
114+
id: state.id,
115+
url: event.urlAfterRedirects,
116+
},
117+
];
118+
// set the new event as our current history index
119+
currentIndex = history.length - 1;
120+
121+
return {
122+
...state,
123+
currentIndex,
124+
event,
125+
history,
126+
};
127+
}
128+
);
129+
130+
#updateRouterHistoryOnNavigationStart = this.updater<NavigationStart>(
131+
(state, event): RouterHistoryState => ({
132+
...state,
133+
id: event.id,
134+
idToRestore: event.restoredState?.navigationId ?? undefined,
135+
event,
136+
trigger: event.navigationTrigger,
137+
})
138+
);
139+
140+
/**
141+
* Update router history on browser navigation end (back, forward, and other
142+
* `popstate` or `pushstate` events).
143+
*/
144+
#updateRouterHistoryOnPopstateNavigationEnd = this.updater<NavigationEnd>(
145+
(state, event): RouterHistoryState => {
146+
let currentIndex = 0;
147+
let { history } = state;
148+
// get the history item that references the idToRestore
149+
const historyIndexToRestore = history.findIndex(
150+
(historyRecord) => historyRecord.id === state.idToRestore
151+
);
152+
153+
// if found, set the current index to that history item and update the id
154+
if (historyIndexToRestore > -1) {
155+
currentIndex = historyIndexToRestore;
156+
history = [
157+
...history.slice(0, historyIndexToRestore),
158+
{
159+
...history[historyIndexToRestore],
160+
id: state.id,
161+
},
162+
...history.slice(historyIndexToRestore + 1),
163+
];
164+
}
165+
166+
return {
167+
...state,
168+
currentIndex,
169+
event,
170+
history,
171+
};
172+
}
173+
);
174+
}
175+
176+
export const initialState: RouterHistoryState = {
177+
currentIndex: 0,
178+
event: undefined,
179+
history: [],
180+
id: 0,
181+
idToRestore: 0,
182+
trigger: undefined,
183+
};

0 commit comments

Comments
 (0)