diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.ts new file mode 100644 index 00000000..1a745557 --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.ts @@ -0,0 +1,177 @@ +import { inject, Injectable, Provider } from '@angular/core'; +import { + Navigation, + NavigationEnd, + NavigationStart, + Router, +} from '@angular/router'; +import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; +import { concatMap, filter, Observable, take } from 'rxjs'; + +interface RouterHistoryRecord { + readonly id: number; + readonly url: string; +} + +interface RouterHistoryState { + readonly currentIndex: number; + readonly event: NavigationStart | NavigationEnd | null; + readonly history: readonly RouterHistoryRecord[]; + readonly id: number; + readonly idToRestore?: number; + readonly trigger?: Navigation['trigger']; +} + +export function provideRouterHistoryStore(): Provider[] { + return [provideComponentStore(RouterHistoryStore)]; +} + +@Injectable() +export class RouterHistoryStore extends ComponentStore { + #router = inject(Router); + + #currentIndex$: Observable = this.select( + (state) => state.currentIndex + ); + #history$: Observable = this.select( + (state) => state.history + ); + #navigationEnd$: Observable = this.#router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd) + ); + #navigationStart$: Observable = this.#router.events.pipe( + filter( + (event): event is NavigationStart => event instanceof NavigationStart + ) + ); + #imperativeNavigationEnd$: Observable = + this.#navigationStart$.pipe( + filter((event) => event.navigationTrigger === 'imperative'), + concatMap(() => this.#navigationEnd$.pipe(take(1))) + ); + #popstateNavigationEnd$: Observable = + this.#navigationStart$.pipe( + filter((event) => event.navigationTrigger === 'popstate'), + concatMap(() => this.#navigationEnd$.pipe(take(1))) + ); + + currentUrl$: Observable = this.select( + this.#navigationEnd$.pipe( + concatMap(() => + this.select( + this.#currentIndex$, + this.#history$, + (currentIndex, history) => [currentIndex, history] as const + ) + ) + ), + ([currentIndex, history]) => history[currentIndex].url + ); + previousUrl$: Observable = this.select( + this.#navigationEnd$.pipe( + concatMap(() => + this.select( + this.#currentIndex$, + this.#history$, + (currentIndex, history) => [currentIndex, history] as const + ) + ) + ), + ([currentIndex, history]) => history[currentIndex - 1]?.url ?? null + ); + + constructor() { + super(initialState); + + this.#updateRouterHistoryOnNavigationStart(this.#navigationStart$); + this.#updateRouterHistoryOnImperativeNavigationEnd( + this.#imperativeNavigationEnd$ + ); + this.#updateRouterHistoryOnPopstateNavigationEnd( + this.#popstateNavigationEnd$ + ); + } + + /** + * Update router history on imperative navigation end (`Router#navigate`, + * `Router#navigateByUrl`, or `RouterLink`). + */ + #updateRouterHistoryOnImperativeNavigationEnd = this.updater( + (state, event): RouterHistoryState => { + let currentIndex = state.currentIndex; + let history = state.history; + // remove all events in history that come after the current index + history = [ + ...history.slice(0, currentIndex), + // add the new event to the end of the history + { + id: state.id, + url: event.urlAfterRedirects, + }, + ]; + // set the new event as our current history index + currentIndex = history.length - 1; + + return { + ...state, + currentIndex, + event, + history, + }; + } + ); + + #updateRouterHistoryOnNavigationStart = this.updater( + (state, event): RouterHistoryState => ({ + ...state, + id: event.id, + idToRestore: event.restoredState?.navigationId ?? undefined, + event, + trigger: event.navigationTrigger, + }) + ); + + /** + * Update router history on browser navigation end (back, forward, and other + * `popstate` events). + */ + #updateRouterHistoryOnPopstateNavigationEnd = this.updater( + (state, event): RouterHistoryState => { + let currentIndex = 0; + let { history } = state; + // get the history item that references the idToRestore + const historyIndexToRestore = history.findIndex( + (historyRecord) => historyRecord.id === state.idToRestore + ); + + // if found, set the current index to that history item and update the id + if (historyIndexToRestore > -1) { + currentIndex = historyIndexToRestore; + history = [ + ...history.slice(0, historyIndexToRestore), + { + ...history[historyIndexToRestore], + id: state.id, + }, + ...history.slice(historyIndexToRestore + 1), + ]; + } + + return { + ...state, + currentIndex, + event, + history, + }; + } + ); +} + +export const initialState: RouterHistoryState = { + currentIndex: 0, + event: null, + history: [], + id: 0, + idToRestore: 0, + trigger: undefined, +};