Skip to content

Commit ef00e9c

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

File tree

1 file changed

+177
-0
lines changed

1 file changed

+177
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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+
previousUrl$: Observable<string | null> = this.select(
71+
this.#navigationEnd$.pipe(
72+
concatMap(() =>
73+
this.select(
74+
this.#currentIndex$,
75+
this.#history$,
76+
(currentIndex, history) => [currentIndex, history] as const
77+
)
78+
)
79+
),
80+
([currentIndex, history]) => history[currentIndex - 1]?.url ?? null
81+
);
82+
83+
constructor() {
84+
super(initialState);
85+
86+
this.#updateRouterHistoryOnNavigationStart(this.#navigationStart$);
87+
this.#updateRouterHistoryOnImperativeNavigationEnd(
88+
this.#imperativeNavigationEnd$
89+
);
90+
this.#updateRouterHistoryOnPopstateNavigationEnd(
91+
this.#popstateNavigationEnd$
92+
);
93+
}
94+
95+
/**
96+
* Update router history on imperative navigation end (`Router#navigate`,
97+
* `Router#navigateByUrl`, or `RouterLink` click).
98+
*/
99+
#updateRouterHistoryOnImperativeNavigationEnd = this.updater<NavigationEnd>(
100+
(state, event): RouterHistoryState => {
101+
let currentIndex = state.currentIndex;
102+
let history = state.history;
103+
// remove all events in history that come after the current index
104+
history = [
105+
...history.slice(0, currentIndex),
106+
// add the new event to the end of the history
107+
{
108+
id: state.id,
109+
url: event.urlAfterRedirects,
110+
},
111+
];
112+
// set the new event as our current history index
113+
currentIndex = history.length - 1;
114+
115+
return {
116+
...state,
117+
currentIndex,
118+
event,
119+
history,
120+
};
121+
}
122+
);
123+
124+
#updateRouterHistoryOnNavigationStart = this.updater<NavigationStart>(
125+
(state, event): RouterHistoryState => ({
126+
...state,
127+
id: event.id,
128+
idToRestore: event.restoredState?.navigationId ?? undefined,
129+
event,
130+
trigger: event.navigationTrigger,
131+
})
132+
);
133+
134+
/**
135+
* Update router history on browser navigation end (back, forward, and other
136+
* `popstate` events).
137+
*/
138+
#updateRouterHistoryOnPopstateNavigationEnd = this.updater<NavigationEnd>(
139+
(state, event): RouterHistoryState => {
140+
let currentIndex = 0;
141+
let { history } = state;
142+
// get the history item that references the idToRestore
143+
const historyIndexToRestore = history.findIndex(
144+
(historyRecord) => historyRecord.id === state.idToRestore
145+
);
146+
147+
// if found, set the current index to that history item and update the id
148+
if (historyIndexToRestore > -1) {
149+
currentIndex = historyIndexToRestore;
150+
history = [
151+
...history.slice(0, historyIndexToRestore),
152+
{
153+
...history[historyIndexToRestore],
154+
id: state.id,
155+
},
156+
...history.slice(historyIndexToRestore + 1),
157+
];
158+
}
159+
160+
return {
161+
...state,
162+
currentIndex,
163+
event,
164+
history,
165+
};
166+
}
167+
);
168+
}
169+
170+
export const initialState: RouterHistoryState = {
171+
currentIndex: 0,
172+
event: undefined,
173+
history: [],
174+
id: 0,
175+
idToRestore: 0,
176+
trigger: undefined,
177+
};

0 commit comments

Comments
 (0)