From e39929b38a0c9fcefa0ff01d55df91dfa9f6440b Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Fri, 23 Dec 2022 14:17:18 +0100 Subject: [PATCH 01/25] feat: add `RouterHistoryStore` --- .../router-history.store.ts | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 packages/router-component-store/src/lib/router-history-store/router-history.store.ts 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 0000000..36a08a4 --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.ts @@ -0,0 +1,183 @@ +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; + 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, + { + debounce: true, + } + ); + 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, + { + debounce: true, + } + ); + + 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` click). + */ + #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 + 1), + // 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` or `pushstate` 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: undefined, + history: [], + id: 0, + idToRestore: 0, + trigger: undefined, +}; From ac97fb94de2d027d7ac74de37136171434ebad5f Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sat, 24 Dec 2022 01:07:25 +0100 Subject: [PATCH 02/25] refactor: add notes on `NavigationCancel` and `NavigationError` events --- .../router-history.store.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 index 36a08a4..a5ce480 100644 --- 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 @@ -26,6 +26,35 @@ export function provideRouterHistoryStore(): Provider[] { return [provideComponentStore(RouterHistoryStore)]; } +// TODO(@LayZeeDK): Handle `NavigationCancel` and `NavigationError` events +// NavigationStart -> NavigationEnd | NavigationCancel | NavigationError +// +// NavigationError resets the URL to what it was before the navigation that caused an error. No new *navigation* is triggered. +// NavigationError reasons: +// - Invalid route path +// NavigationError(id: 3, url: '/an-invalid/path', error: Error: Cannot match any routes. URL Segment: 'an-invalid/path') +// - Router resolver throws +// - Route matcher throws +// - Routed component throws in constructor (or a lifecycle hook?) +// - Lazy route chunk file is not found (bundles updated and the user needs to refresh) +// RouterTestingModule.withRoutes([ +// { +// path: 'stale-chunk', +// loadChildren: () => +// Promise.reject({ name: 'ChunkLoadError', message: 'ChunkLoadError' }), +// // or () => { throw { name: 'ChunkLoadError', message: 'ChunkLoadError' }; } +// }, +// ]), +// +// What is the URL after each of the following reasons? +// NavigationCancel reasons: +// NavigationCancel#code: NavigationCancellationCode +// - GuardRejected: A navigation failed because a guard returned `false`. +// - NoDataFromResolver: A navigation failed because one of the resolvers completed without emiting a value. +// - Redirect: A navigation failed because a guard returned a `UrlTree` to redirect. +// - SupersededByNewNavigation: A navigation failed because a more recent navigation started. +// NavigationCancel { id: 3, url: "/company", reason: "Navigation ID 3 is not equal to the current navigation id 4" } + @Injectable() export class RouterHistoryStore extends ComponentStore { #router = inject(Router); From 0c288546d653f4bb441c12793f44d3c18fd23737 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Sat, 24 Dec 2022 02:15:01 +0100 Subject: [PATCH 03/25] test: cover `RouterHistoryStore` --- .../router-history.store.spec.ts | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts new file mode 100644 index 0000000..12152f3 --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -0,0 +1,121 @@ +import { AsyncPipe, Location, NgIf } from '@angular/common'; +import { Component, inject, NgZone } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Router, RouterLink, RouterOutlet } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { + provideRouterHistoryStore, + RouterHistoryStore, +} from './router-history.store'; + +function createTestComponent(name: string, selector: string) { + @Component({ standalone: true, selector, template: name }) + class TestComponent {} + + return TestComponent; +} + +@Component({ + standalone: true, + selector: 'ngw-test-app', + imports: [AsyncPipe, NgIf, RouterLink, RouterOutlet], + template: ` + Back + + Home + About + Company + Products + + + `, +}) +class TestAppComponent { + #location = inject(Location); + + protected routerHistory = inject(RouterHistoryStore); + + onBack() { + this.#location.back(); + } +} + +describe(RouterHistoryStore.name, () => { + async function setup() { + TestBed.configureTestingModule({ + imports: [ + TestAppComponent, + RouterTestingModule.withRoutes([ + { path: '', pathMatch: 'full', redirectTo: 'home' }, + { + path: 'home', + component: createTestComponent('HomeComponent', 'test-home'), + }, + { + path: 'about', + component: createTestComponent('AboutComponent', 'test-about'), + }, + { + path: 'company', + component: createTestComponent('CompanyComponent', 'test-company'), + }, + { + path: 'products', + component: createTestComponent( + 'ProductsComponent', + 'test-products' + ), + }, + ]), + ], + providers: [provideRouterHistoryStore()], + }); + + const rootFixture = TestBed.createComponent(TestAppComponent); + const router = TestBed.inject(Router); + const ngZone = TestBed.inject(NgZone); + const routerHistory = TestBed.inject(RouterHistoryStore); + + rootFixture.autoDetectChanges(); + ngZone.run(() => router.initialNavigation()); + + return { + async click(selector: string) { + const link = rootFixture.debugElement.query(By.css(selector)) + .nativeElement as HTMLElement; + ngZone.run(() => link.click()); + await rootFixture.whenStable(); + }, + routerHistory, + }; + } + + it('the URLs behave like the History API when navigating back', async () => { + const { click, routerHistory } = await setup(); + let currentUrl: string | undefined; + routerHistory.currentUrl$.subscribe((url) => { + currentUrl = url; + }); + let previousUrl: string | null | undefined; + routerHistory.previousUrl$.subscribe((url) => { + previousUrl = url; + }); + + // At Home + await click('#about-link'); + // At About + await click('#company-link'); + // At Company + await click('#back-link'); + // At About + + expect(currentUrl).toBe('/about'); + expect(previousUrl).toBe('/home'); + }); +}); From 61db5b1658fa3a4e915bda0b992f3b57b711c87a Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 26 Dec 2022 04:44:51 +0100 Subject: [PATCH 04/25] refactor: refactor `RouterHistoryStore` to only store the navigation history in its internal state --- .../router-history.store.spec.ts | 5 +- .../router-history.store.ts | 209 +++++++----------- 2 files changed, 81 insertions(+), 133 deletions(-) diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts index 12152f3..c1e281c 100644 --- a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -21,12 +21,13 @@ function createTestComponent(name: string, selector: string) { selector: 'ngw-test-app', imports: [AsyncPipe, NgIf, RouterLink, RouterOutlet], template: ` - Back + > --> + Back Home About 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 index a5ce480..fc1bfd8 100644 --- 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 @@ -1,27 +1,17 @@ import { inject, Injectable, Provider } from '@angular/core'; -import { - Navigation, - NavigationEnd, - NavigationStart, - Router, -} from '@angular/router'; +import { 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; -} +import { filter, Observable } from 'rxjs'; interface RouterHistoryState { - readonly currentIndex: number; - readonly event?: NavigationStart | NavigationEnd; - readonly history: readonly RouterHistoryRecord[]; - readonly id: number; - readonly idToRestore?: number; - readonly trigger?: Navigation['trigger']; + readonly history: NavigationHistory; } +type CompleteNavigation = readonly [NavigationStart, NavigationEnd]; +type NavigationHistory = Record; +type NavigationSequence = PendingNavigation | CompleteNavigation; +type PendingNavigation = readonly [NavigationStart]; + export function provideRouterHistoryStore(): Provider[] { return [provideComponentStore(RouterHistoryStore)]; } @@ -59,11 +49,8 @@ export function provideRouterHistoryStore(): Provider[] { export class RouterHistoryStore extends ComponentStore { #router = inject(Router); - #currentIndex$: Observable = this.select( - (state) => state.currentIndex - ); - #history$: Observable = this.select( - (state) => state.history + #history$ = this.select((state) => state.history).pipe( + filter((history) => Object.keys(history).length > 0) ); #navigationEnd$: Observable = this.#router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd) @@ -73,43 +60,49 @@ export class RouterHistoryStore extends ComponentStore { (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, + #maxCompletedNavigationId$ = this.select(this.#history$, (history) => + Number( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Object.entries(history) + .reverse() + .find(([, navigation]) => navigation.length === 2)![0] + ) + ); + #latestCompletedNavigation$ = this.select( + this.#maxCompletedNavigationId$, + this.#history$, + (maxCompletedNavigationId, history) => + history[maxCompletedNavigationId] as CompleteNavigation, { debounce: true, } ); - 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, + + currentUrl$: Observable = this.select( + this.#latestCompletedNavigation$, + ([, end]) => end.urlAfterRedirects + ); + previousUrl$: Observable = this.select( + this.#history$, + this.#maxCompletedNavigationId$, + (history, maxCompletedNavigationId) => { + if (maxCompletedNavigationId === 1) { + return undefined; + } + + const [completedNavigationSourceStart] = this.#getNavigationSource( + maxCompletedNavigationId, + history + ); + const previousNavigationId = completedNavigationSourceStart.id - 1; + const [, previousNavigationSourceEnd] = this.#getNavigationSource( + previousNavigationId, + history + ); + + return previousNavigationSourceEnd.urlAfterRedirects; + }, { debounce: true, } @@ -118,95 +111,49 @@ export class RouterHistoryStore extends ComponentStore { constructor() { super(initialState); - this.#updateRouterHistoryOnNavigationStart(this.#navigationStart$); - this.#updateRouterHistoryOnImperativeNavigationEnd( - this.#imperativeNavigationEnd$ - ); - this.#updateRouterHistoryOnPopstateNavigationEnd( - this.#popstateNavigationEnd$ - ); + this.#addNavigationStart(this.#navigationStart$); + this.#addNavigationEnd(this.#navigationEnd$); } - /** - * Update router history on imperative navigation end (`Router#navigate`, - * `Router#navigateByUrl`, or `RouterLink` click). - */ - #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 + 1), - // 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, - }; - } + #addNavigationEnd = this.updater( + (state, event): RouterHistoryState => ({ + ...state, + history: { + ...state.history, + [event.id]: [state.history[event.id][0], event], + }, + }) ); - #updateRouterHistoryOnNavigationStart = this.updater( + #addNavigationStart = this.updater( (state, event): RouterHistoryState => ({ ...state, - id: event.id, - idToRestore: event.restoredState?.navigationId ?? undefined, - event, - trigger: event.navigationTrigger, + history: { + ...state.history, + [event.id]: [event], + }, }) ); - /** - * Update router history on browser navigation end (back, forward, and other - * `popstate` or `pushstate` 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), + #getNavigationSource( + navigationId: number, + history: NavigationHistory + ): CompleteNavigation { + let navigation = history[navigationId]; + + while (navigation[0].navigationTrigger === 'popstate') { + navigation = + history[ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + navigation[0].restoredState!.navigationId ]; - } - - return { - ...state, - currentIndex, - event, - history, - }; + navigationId = navigation[0].id; } - ); + + return navigation as CompleteNavigation; + } } export const initialState: RouterHistoryState = { - currentIndex: 0, - event: undefined, history: [], - id: 0, - idToRestore: 0, - trigger: undefined, }; From 017e22200a39182f792c59dbe2ef092f4773622e Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 26 Dec 2022 04:53:15 +0100 Subject: [PATCH 05/25] test: cover `RouterHistoryStore` with more test cases --- .../router-history.store.spec.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts index c1e281c..3da2c76 100644 --- a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -97,7 +97,34 @@ describe(RouterHistoryStore.name, () => { }; } + it('the URLs behave like the History API when navigating using links', async () => { + expect.assertions(2); + + const { click, routerHistory } = await setup(); + let currentUrl: string | undefined; + routerHistory.currentUrl$.subscribe((url) => { + currentUrl = url; + }); + let previousUrl: string | null | undefined; + routerHistory.previousUrl$.subscribe((url) => { + previousUrl = url; + }); + + // At Home + await click('#about-link'); + // At About + await click('#company-link'); + // At Company + await click('#products-link'); + // At Products + + expect(currentUrl).toBe('/products'); + expect(previousUrl).toBe('/company'); + }); + it('the URLs behave like the History API when navigating back', async () => { + expect.assertions(2); + const { click, routerHistory } = await setup(); let currentUrl: string | undefined; routerHistory.currentUrl$.subscribe((url) => { @@ -119,4 +146,31 @@ describe(RouterHistoryStore.name, () => { expect(currentUrl).toBe('/about'); expect(previousUrl).toBe('/home'); }); + + it('the URLs behave like the History API when navigating back then using links', async () => { + expect.assertions(2); + + const { click, routerHistory } = await setup(); + let currentUrl: string | undefined; + routerHistory.currentUrl$.subscribe((url) => { + currentUrl = url; + }); + let previousUrl: string | null | undefined; + routerHistory.previousUrl$.subscribe((url) => { + previousUrl = url; + }); + + // At Home + await click('#about-link'); + // At About + await click('#company-link'); + // At Company + await click('#back-link'); + // At About + await click('#products-link'); + // At Products + + expect(currentUrl).toBe('/products'); + expect(previousUrl).toBe('/about'); + }); }); From f0b392461b5ab5824c37a9abc0eff47fd058b75a Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 28 Dec 2022 00:56:24 +0100 Subject: [PATCH 06/25] fix: initialize `RouterHistoryStore` on app initialization --- .../router-history.store.spec.ts | 50 +++----- .../router-history.store.ts | 110 ++++++++++++++++-- 2 files changed, 113 insertions(+), 47 deletions(-) diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts index 3da2c76..e960f1b 100644 --- a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -4,6 +4,7 @@ import { TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { Router, RouterLink, RouterOutlet } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; +import { firstValueFrom } from 'rxjs'; import { provideRouterHistoryStore, RouterHistoryStore, @@ -21,13 +22,13 @@ function createTestComponent(name: string, selector: string) { selector: 'ngw-test-app', imports: [AsyncPipe, NgIf, RouterLink, RouterOutlet], template: ` - - Back + [href]="previousUrl" + (click)="onBack($event)" + >< Back Home About @@ -42,7 +43,8 @@ class TestAppComponent { protected routerHistory = inject(RouterHistoryStore); - onBack() { + onBack(event: MouseEvent) { + event.preventDefault(); this.#location.back(); } } @@ -101,14 +103,6 @@ describe(RouterHistoryStore.name, () => { expect.assertions(2); const { click, routerHistory } = await setup(); - let currentUrl: string | undefined; - routerHistory.currentUrl$.subscribe((url) => { - currentUrl = url; - }); - let previousUrl: string | null | undefined; - routerHistory.previousUrl$.subscribe((url) => { - previousUrl = url; - }); // At Home await click('#about-link'); @@ -118,22 +112,14 @@ describe(RouterHistoryStore.name, () => { await click('#products-link'); // At Products - expect(currentUrl).toBe('/products'); - expect(previousUrl).toBe('/company'); + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/company'); }); it('the URLs behave like the History API when navigating back', async () => { expect.assertions(2); const { click, routerHistory } = await setup(); - let currentUrl: string | undefined; - routerHistory.currentUrl$.subscribe((url) => { - currentUrl = url; - }); - let previousUrl: string | null | undefined; - routerHistory.previousUrl$.subscribe((url) => { - previousUrl = url; - }); // At Home await click('#about-link'); @@ -143,22 +129,14 @@ describe(RouterHistoryStore.name, () => { await click('#back-link'); // At About - expect(currentUrl).toBe('/about'); - expect(previousUrl).toBe('/home'); + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home'); }); it('the URLs behave like the History API when navigating back then using links', async () => { expect.assertions(2); const { click, routerHistory } = await setup(); - let currentUrl: string | undefined; - routerHistory.currentUrl$.subscribe((url) => { - currentUrl = url; - }); - let previousUrl: string | null | undefined; - routerHistory.previousUrl$.subscribe((url) => { - previousUrl = url; - }); // At Home await click('#about-link'); @@ -170,7 +148,7 @@ describe(RouterHistoryStore.name, () => { await click('#products-link'); // At Products - expect(currentUrl).toBe('/products'); - expect(previousUrl).toBe('/about'); + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about'); }); }); 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 index fc1bfd8..cd3c7e3 100644 --- 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 @@ -1,9 +1,18 @@ -import { inject, Injectable, Provider } from '@angular/core'; +import { + APP_INITIALIZER, + FactoryProvider, + inject, + Injectable, + Provider, +} from '@angular/core'; import { NavigationEnd, NavigationStart, Router } from '@angular/router'; import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; import { filter, Observable } from 'rxjs'; interface RouterHistoryState { + /** + * The history of all navigations. + */ readonly history: NavigationHistory; } @@ -12,8 +21,17 @@ type NavigationHistory = Record; type NavigationSequence = PendingNavigation | CompleteNavigation; type PendingNavigation = readonly [NavigationStart]; +/** + * Provide and initialize the `RouterHistoryStore`. + * + * @remarks + * Must be provided by the root injector to capture all navigation events. + */ export function provideRouterHistoryStore(): Provider[] { - return [provideComponentStore(RouterHistoryStore)]; + return [ + provideComponentStore(RouterHistoryStore), + routerHistoryStoreInitializer, + ]; } // TODO(@LayZeeDK): Handle `NavigationCancel` and `NavigationError` events @@ -49,26 +67,45 @@ export function provideRouterHistoryStore(): Provider[] { export class RouterHistoryStore extends ComponentStore { #router = inject(Router); + /** + * The history of all navigations. + */ #history$ = this.select((state) => state.history).pipe( filter((history) => Object.keys(history).length > 0) ); + /** + * All `NavigationEnd` events. + */ #navigationEnd$: Observable = this.#router.events.pipe( filter((event): event is NavigationEnd => event instanceof NavigationEnd) ); + /** + * All `NavigationStart` events. + */ #navigationStart$: Observable = this.#router.events.pipe( filter( (event): event is NavigationStart => event instanceof NavigationStart ) ); - #maxCompletedNavigationId$ = this.select(this.#history$, (history) => - Number( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.entries(history) - .reverse() - .find(([, navigation]) => navigation.length === 2)![0] - ) + /** + * The navigation ID of the most recent completed navigation. + */ + #maxCompletedNavigationId$ = this.select( + this.#history$.pipe(filter((history) => (history[1] ?? []).length > 1)), + (history) => + Number( + // This callback is only triggered when at least one navigation has + // completed + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Object.entries(history) + .reverse() + .find(([, navigation]) => navigation.length === 2)![0] + ) ); + /** + * The most recent completed navigation. + */ #latestCompletedNavigation$ = this.select( this.#maxCompletedNavigationId$, this.#history$, @@ -79,10 +116,19 @@ export class RouterHistoryStore extends ComponentStore { } ); + /** + * The current URL. + */ currentUrl$: Observable = this.select( this.#latestCompletedNavigation$, ([, end]) => end.urlAfterRedirects ); + /** + * The previous URL when taking `popstate` events into account. + * + * `undefined` is emitted when the current navigation is the first in the + * navigation history. + */ previousUrl$: Observable = this.select( this.#history$, this.#maxCompletedNavigationId$, @@ -95,6 +141,11 @@ export class RouterHistoryStore extends ComponentStore { maxCompletedNavigationId, history ); + + if (completedNavigationSourceStart.id === 1) { + return undefined; + } + const previousNavigationId = completedNavigationSourceStart.id - 1; const [, previousNavigationSourceEnd] = this.#getNavigationSource( previousNavigationId, @@ -115,6 +166,9 @@ export class RouterHistoryStore extends ComponentStore { this.#addNavigationEnd(this.#navigationEnd$); } + /** + * Add a `NavigationEnd` event to the navigation history. + */ #addNavigationEnd = this.updater( (state, event): RouterHistoryState => ({ ...state, @@ -125,6 +179,9 @@ export class RouterHistoryStore extends ComponentStore { }) ); + /** + * Add a `NavigationStart` event to the navigation history. + */ #addNavigationStart = this.updater( (state, event): RouterHistoryState => ({ ...state, @@ -135,6 +192,16 @@ export class RouterHistoryStore extends ComponentStore { }) ); + /** + * Search the specified navigation history to find the source of the + * specified navigation event. + * + * This takes `popstate` navigation events into account. + * + * @param navigationId The ID of the navigation to trace. + * @param history The navigation history to search. + * @returns The source navigation. + */ #getNavigationSource( navigationId: number, history: NavigationHistory @@ -144,16 +211,37 @@ export class RouterHistoryStore extends ComponentStore { while (navigation[0].navigationTrigger === 'popstate') { navigation = history[ + // Navigation events triggered by `popstate` always have a + // `restoredState` // eslint-disable-next-line @typescript-eslint/no-non-null-assertion navigation[0].restoredState!.navigationId ]; - navigationId = navigation[0].id; } return navigation as CompleteNavigation; } } -export const initialState: RouterHistoryState = { +/** + * The initial internal state of the `RouterHistoryStore`. + */ +const initialState: RouterHistoryState = { history: [], }; + +const initializeRouterHistoryStoreFactory = + // Inject the RouterHistoryStore to eagerly initialize it. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_initializedRouterHistoryStore: RouterHistoryStore) => (): void => undefined; +/** + * Eagerly initialize the `RouterHistoryStore` to subscribe to all relevant + * router navigation events. + */ +const routerHistoryStoreInitializer: FactoryProvider = { + provide: APP_INITIALIZER, + multi: true, + deps: [RouterHistoryStore], + useFactory: + // eslint-disable-next-line @typescript-eslint/no-unused-vars + initializeRouterHistoryStoreFactory, +}; From 61e229ccf293956b7d698f2bf14945972ad77f56 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Mon, 2 Jan 2023 23:19:46 +0100 Subject: [PATCH 07/25] refactor: add notes on `NavigationSkipped` --- .../src/lib/router-history-store/router-history.store.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index cd3c7e3..0de35c2 100644 --- 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 @@ -36,6 +36,7 @@ export function provideRouterHistoryStore(): Provider[] { // TODO(@LayZeeDK): Handle `NavigationCancel` and `NavigationError` events // NavigationStart -> NavigationEnd | NavigationCancel | NavigationError +// NavigationSkipped -> (end) // // NavigationError resets the URL to what it was before the navigation that caused an error. No new *navigation* is triggered. // NavigationError reasons: @@ -62,6 +63,10 @@ export function provideRouterHistoryStore(): Provider[] { // - Redirect: A navigation failed because a guard returned a `UrlTree` to redirect. // - SupersededByNewNavigation: A navigation failed because a more recent navigation started. // NavigationCancel { id: 3, url: "/company", reason: "Navigation ID 3 is not equal to the current navigation id 4" } +// +// NavigationSkipped reasons: +// - `location` change to unsupported URL. The `UrlHandlingStrategy` cannot process the current or the target URL. +// - `onSameUrlNavigation` is set to `ignore` (the default) and the target URL is the same as the current URL. @Injectable() export class RouterHistoryStore extends ComponentStore { From 7febcb5659f911a89a8be2c3336141985ed43c67 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 4 Jan 2023 22:34:52 +0100 Subject: [PATCH 08/25] refactor: remove TODO and notes --- .../router-history.store.ts | 34 ------------------- 1 file changed, 34 deletions(-) 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 index 0de35c2..c12d61e 100644 --- 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 @@ -34,40 +34,6 @@ export function provideRouterHistoryStore(): Provider[] { ]; } -// TODO(@LayZeeDK): Handle `NavigationCancel` and `NavigationError` events -// NavigationStart -> NavigationEnd | NavigationCancel | NavigationError -// NavigationSkipped -> (end) -// -// NavigationError resets the URL to what it was before the navigation that caused an error. No new *navigation* is triggered. -// NavigationError reasons: -// - Invalid route path -// NavigationError(id: 3, url: '/an-invalid/path', error: Error: Cannot match any routes. URL Segment: 'an-invalid/path') -// - Router resolver throws -// - Route matcher throws -// - Routed component throws in constructor (or a lifecycle hook?) -// - Lazy route chunk file is not found (bundles updated and the user needs to refresh) -// RouterTestingModule.withRoutes([ -// { -// path: 'stale-chunk', -// loadChildren: () => -// Promise.reject({ name: 'ChunkLoadError', message: 'ChunkLoadError' }), -// // or () => { throw { name: 'ChunkLoadError', message: 'ChunkLoadError' }; } -// }, -// ]), -// -// What is the URL after each of the following reasons? -// NavigationCancel reasons: -// NavigationCancel#code: NavigationCancellationCode -// - GuardRejected: A navigation failed because a guard returned `false`. -// - NoDataFromResolver: A navigation failed because one of the resolvers completed without emiting a value. -// - Redirect: A navigation failed because a guard returned a `UrlTree` to redirect. -// - SupersededByNewNavigation: A navigation failed because a more recent navigation started. -// NavigationCancel { id: 3, url: "/company", reason: "Navigation ID 3 is not equal to the current navigation id 4" } -// -// NavigationSkipped reasons: -// - `location` change to unsupported URL. The `UrlHandlingStrategy` cannot process the current or the target URL. -// - `onSameUrlNavigation` is set to `ignore` (the default) and the target URL is the same as the current URL. - @Injectable() export class RouterHistoryStore extends ComponentStore { #router = inject(Router); From 619848885b7526762c882878eef0cfab1710bfbd Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 4 Jan 2023 22:37:32 +0100 Subject: [PATCH 09/25] refactor: rename router sequences --- .../router-history.store.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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 index c12d61e..c3ccbb4 100644 --- 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 @@ -13,13 +13,13 @@ interface RouterHistoryState { /** * The history of all navigations. */ - readonly history: NavigationHistory; + readonly history: RouterNavigationHistory; } -type CompleteNavigation = readonly [NavigationStart, NavigationEnd]; -type NavigationHistory = Record; -type NavigationSequence = PendingNavigation | CompleteNavigation; -type PendingNavigation = readonly [NavigationStart]; +type RouterNavigatedSequence = readonly [NavigationStart, NavigationEnd]; +type RouterNavigationHistory = Record; +type RouterNavigationSequence = RouterRequestSequence | RouterNavigatedSequence; +type RouterRequestSequence = readonly [NavigationStart]; /** * Provide and initialize the `RouterHistoryStore`. @@ -81,7 +81,7 @@ export class RouterHistoryStore extends ComponentStore { this.#maxCompletedNavigationId$, this.#history$, (maxCompletedNavigationId, history) => - history[maxCompletedNavigationId] as CompleteNavigation, + history[maxCompletedNavigationId] as RouterNavigatedSequence, { debounce: true, } @@ -175,8 +175,8 @@ export class RouterHistoryStore extends ComponentStore { */ #getNavigationSource( navigationId: number, - history: NavigationHistory - ): CompleteNavigation { + history: RouterNavigationHistory + ): RouterNavigatedSequence { let navigation = history[navigationId]; while (navigation[0].navigationTrigger === 'popstate') { @@ -189,7 +189,7 @@ export class RouterHistoryStore extends ComponentStore { ]; } - return navigation as CompleteNavigation; + return navigation as RouterNavigatedSequence; } } From 70fccc3b09d31138dd4a28c7d58c77ffa4c29faf Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 4 Jan 2023 22:38:27 +0100 Subject: [PATCH 10/25] docs: explain history key --- .../src/lib/router-history-store/router-history.store.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index c3ccbb4..d40cafc 100644 --- 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 @@ -11,7 +11,9 @@ import { filter, Observable } from 'rxjs'; interface RouterHistoryState { /** - * The history of all navigations. + * The history of all router navigation sequences. + * + * The key is the navigation ID. */ readonly history: RouterNavigationHistory; } From 84805a38c798a48102d2207e577b1f35c3220dfa Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 4 Jan 2023 22:47:05 +0100 Subject: [PATCH 11/25] refactor: refactor `#maxCompletedNavigationId$` --- .../router-history.store.ts | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) 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 index d40cafc..39ad32f 100644 --- 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 @@ -23,6 +23,16 @@ type RouterNavigationHistory = Record; type RouterNavigationSequence = RouterRequestSequence | RouterNavigatedSequence; type RouterRequestSequence = readonly [NavigationStart]; +function isRouterNavigatedSequence( + sequence: RouterNavigationSequence +): sequence is RouterNavigatedSequence { + return ( + sequence.length === 2 && + sequence[0] instanceof NavigationStart && + sequence[1] instanceof NavigationEnd + ); +} + /** * Provide and initialize the `RouterHistoryStore`. * @@ -62,10 +72,10 @@ export class RouterHistoryStore extends ComponentStore { ); /** - * The navigation ID of the most recent completed navigation. + * The navigation ID of the most recent router navigated sequence. */ - #maxCompletedNavigationId$ = this.select( - this.#history$.pipe(filter((history) => (history[1] ?? []).length > 1)), + #maxRouterNavigatedSequenceId$ = this.select( + this.#history$.pipe(filter(this.#selectHasRouterNavigated)), (history) => Number( // This callback is only triggered when at least one navigation has @@ -79,11 +89,11 @@ export class RouterHistoryStore extends ComponentStore { /** * The most recent completed navigation. */ - #latestCompletedNavigation$ = this.select( - this.#maxCompletedNavigationId$, + #latestRouterNavigatedSequence$ = this.select( + this.#maxRouterNavigatedSequenceId$, this.#history$, - (maxCompletedNavigationId, history) => - history[maxCompletedNavigationId] as RouterNavigatedSequence, + (maxRouterNavigatedSequenceId, history) => + history[maxRouterNavigatedSequenceId] as RouterNavigatedSequence, { debounce: true, } @@ -93,7 +103,7 @@ export class RouterHistoryStore extends ComponentStore { * The current URL. */ currentUrl$: Observable = this.select( - this.#latestCompletedNavigation$, + this.#latestRouterNavigatedSequence$, ([, end]) => end.urlAfterRedirects ); /** @@ -104,7 +114,7 @@ export class RouterHistoryStore extends ComponentStore { */ previousUrl$: Observable = this.select( this.#history$, - this.#maxCompletedNavigationId$, + this.#maxRouterNavigatedSequenceId$, (history, maxCompletedNavigationId) => { if (maxCompletedNavigationId === 1) { return undefined; @@ -193,6 +203,13 @@ export class RouterHistoryStore extends ComponentStore { return navigation as RouterNavigatedSequence; } + + #selectHasRouterNavigated(history: RouterNavigationHistory): boolean { + const firstNavigationId = 1; + const firstNavigation = history[firstNavigationId] ?? []; + + return isRouterNavigatedSequence(firstNavigation); + } } /** From 83702813e08230f98765a9b21bbfa67fd9cc7b7a Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Wed, 4 Jan 2023 23:39:48 +0100 Subject: [PATCH 12/25] refactor: use router navigated observable connected to a single updater --- .../router-history.store.ts | 142 ++++++++---------- 1 file changed, 63 insertions(+), 79 deletions(-) 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 index 39ad32f..2fdbbc8 100644 --- 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 @@ -7,31 +7,24 @@ import { } from '@angular/core'; import { NavigationEnd, NavigationStart, Router } from '@angular/router'; import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; -import { filter, Observable } from 'rxjs'; +import { filter, map, Observable, switchMap, take } from 'rxjs'; +import { filterRouterEvents } from '../filter-router-event.operator'; interface RouterHistoryState { /** - * The history of all router navigation sequences. + * The history of all router navigated sequences. * * The key is the navigation ID. */ - readonly history: RouterNavigationHistory; + readonly history: RouterNavigatedHistory; + /** + * The ID of the most recent router navigated sequence events. + */ + readonly maxNavigatedId?: number; } type RouterNavigatedSequence = readonly [NavigationStart, NavigationEnd]; -type RouterNavigationHistory = Record; -type RouterNavigationSequence = RouterRequestSequence | RouterNavigatedSequence; -type RouterRequestSequence = readonly [NavigationStart]; - -function isRouterNavigatedSequence( - sequence: RouterNavigationSequence -): sequence is RouterNavigatedSequence { - return ( - sequence.length === 2 && - sequence[0] instanceof NavigationStart && - sequence[1] instanceof NavigationEnd - ); -} +type RouterNavigatedHistory = Readonly>; /** * Provide and initialize the `RouterHistoryStore`. @@ -56,44 +49,48 @@ export class RouterHistoryStore extends ComponentStore { #history$ = this.select((state) => state.history).pipe( filter((history) => Object.keys(history).length > 0) ); + #maxNavigatedId$ = this.select((state) => state.maxNavigatedId).pipe( + filter( + (maxNavigatedId): maxNavigatedId is number => maxNavigatedId !== undefined + ) + ); /** * All `NavigationEnd` events. */ #navigationEnd$: Observable = this.#router.events.pipe( - filter((event): event is NavigationEnd => event instanceof NavigationEnd) + filterRouterEvents(NavigationEnd) ); /** * All `NavigationStart` events. */ #navigationStart$: Observable = this.#router.events.pipe( - filter( - (event): event is NavigationStart => event instanceof NavigationStart - ) + filterRouterEvents(NavigationStart) ); - /** - * The navigation ID of the most recent router navigated sequence. + * All router navigated sequences, that is `NavigationStart` followed by `NavigationEnd`. */ - #maxRouterNavigatedSequenceId$ = this.select( - this.#history$.pipe(filter(this.#selectHasRouterNavigated)), - (history) => - Number( - // This callback is only triggered when at least one navigation has - // completed - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Object.entries(history) - .reverse() - .find(([, navigation]) => navigation.length === 2)![0] + #routerNavigated$: Observable = + this.#navigationStart$.pipe( + switchMap((navigationStart) => + this.#navigationEnd$.pipe( + filter((navigationEnd) => navigationEnd.id === navigationStart.id), + take(1), + map( + (navigationEnd) => + [navigationStart, navigationEnd] as RouterNavigatedSequence + ) + ) ) - ); + ); + /** * The most recent completed navigation. */ #latestRouterNavigatedSequence$ = this.select( - this.#maxRouterNavigatedSequenceId$, + this.#maxNavigatedId$, this.#history$, - (maxRouterNavigatedSequenceId, history) => - history[maxRouterNavigatedSequenceId] as RouterNavigatedSequence, + (maxNavigatedId, history) => + history[maxNavigatedId] as RouterNavigatedSequence, { debounce: true, } @@ -104,7 +101,7 @@ export class RouterHistoryStore extends ComponentStore { */ currentUrl$: Observable = this.select( this.#latestRouterNavigatedSequence$, - ([, end]) => end.urlAfterRedirects + ([, navigationEnd]) => navigationEnd.urlAfterRedirects ); /** * The previous URL when taking `popstate` events into account. @@ -114,28 +111,28 @@ export class RouterHistoryStore extends ComponentStore { */ previousUrl$: Observable = this.select( this.#history$, - this.#maxRouterNavigatedSequenceId$, - (history, maxCompletedNavigationId) => { - if (maxCompletedNavigationId === 1) { + this.#maxNavigatedId$, + (history, maxNavigatedId) => { + if (maxNavigatedId === 1) { return undefined; } - const [completedNavigationSourceStart] = this.#getNavigationSource( - maxCompletedNavigationId, + const [sourceNavigationStart] = this.#getNavigationSource( + maxNavigatedId, history ); - if (completedNavigationSourceStart.id === 1) { + if (sourceNavigationStart.id === 1) { return undefined; } - const previousNavigationId = completedNavigationSourceStart.id - 1; - const [, previousNavigationSourceEnd] = this.#getNavigationSource( + const previousNavigationId = sourceNavigationStart.id - 1; + const [, previousNavigationEnd] = this.#getNavigationSource( previousNavigationId, history ); - return previousNavigationSourceEnd.urlAfterRedirects; + return previousNavigationEnd.urlAfterRedirects; }, { debounce: true, @@ -145,34 +142,28 @@ export class RouterHistoryStore extends ComponentStore { constructor() { super(initialState); - this.#addNavigationStart(this.#navigationStart$); - this.#addNavigationEnd(this.#navigationEnd$); + this.#addRouterNavigatedSequence(this.#routerNavigated$); } /** - * Add a `NavigationEnd` event to the navigation history. - */ - #addNavigationEnd = this.updater( - (state, event): RouterHistoryState => ({ - ...state, - history: { - ...state.history, - [event.id]: [state.history[event.id][0], event], - }, - }) - ); - - /** - * Add a `NavigationStart` event to the navigation history. + * Add a router navigated sequence to the router navigated history. */ - #addNavigationStart = this.updater( - (state, event): RouterHistoryState => ({ - ...state, - history: { - ...state.history, - [event.id]: [event], - }, - }) + #addRouterNavigatedSequence = this.updater( + (state, routerNavigated): RouterHistoryState => { + const [{ id: navigationId }] = routerNavigated; + + return { + ...state, + history: { + ...state.history, + [navigationId]: routerNavigated, + }, + maxNavigatedId: + navigationId > (state.maxNavigatedId ?? 0) + ? navigationId + : state.maxNavigatedId, + }; + } ); /** @@ -187,7 +178,7 @@ export class RouterHistoryStore extends ComponentStore { */ #getNavigationSource( navigationId: number, - history: RouterNavigationHistory + history: RouterNavigatedHistory ): RouterNavigatedSequence { let navigation = history[navigationId]; @@ -201,14 +192,7 @@ export class RouterHistoryStore extends ComponentStore { ]; } - return navigation as RouterNavigatedSequence; - } - - #selectHasRouterNavigated(history: RouterNavigationHistory): boolean { - const firstNavigationId = 1; - const firstNavigation = history[firstNavigationId] ?? []; - - return isRouterNavigatedSequence(firstNavigation); + return navigation; } } From 9c694e429ebfe07c19626a5906e3870833dd3a28 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 00:53:19 +0100 Subject: [PATCH 13/25] refactor: extract `PopstateNavigationStart` type and type guard --- .../popstate-navigation-start.ts | 20 ++++++++ .../router-history.store.ts | 11 ++--- .../src/lib/util-types/non-nullish.ts | 40 ++++++++++++++++ .../src/lib/util-types/override.ts | 47 +++++++++++++++++++ 4 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 packages/router-component-store/src/lib/router-history-store/popstate-navigation-start.ts create mode 100644 packages/router-component-store/src/lib/util-types/non-nullish.ts create mode 100644 packages/router-component-store/src/lib/util-types/override.ts diff --git a/packages/router-component-store/src/lib/router-history-store/popstate-navigation-start.ts b/packages/router-component-store/src/lib/router-history-store/popstate-navigation-start.ts new file mode 100644 index 0000000..7b085ce --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/popstate-navigation-start.ts @@ -0,0 +1,20 @@ +import { NavigationStart } from '@angular/router'; +import { NonNullish } from '../util-types/non-nullish'; +import { Override } from '../util-types/override'; + +/** + * A `NavigationStart` event triggered by a `popstate` event. + */ +export type PopstateNavigationStart = Override< + NavigationStart, + { + navigationTrigger: 'popstate'; + } +> & + NonNullish>; + +export function isPopstateNavigationStart( + event: NavigationStart +): event is PopstateNavigationStart { + return event.navigationTrigger === 'popstate'; +} 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 index 2fdbbc8..74d0932 100644 --- 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 @@ -9,6 +9,7 @@ import { NavigationEnd, NavigationStart, Router } from '@angular/router'; import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; import { filter, map, Observable, switchMap, take } from 'rxjs'; import { filterRouterEvents } from '../filter-router-event.operator'; +import { isPopstateNavigationStart } from './popstate-navigation-start'; interface RouterHistoryState { /** @@ -182,14 +183,8 @@ export class RouterHistoryStore extends ComponentStore { ): RouterNavigatedSequence { let navigation = history[navigationId]; - while (navigation[0].navigationTrigger === 'popstate') { - navigation = - history[ - // Navigation events triggered by `popstate` always have a - // `restoredState` - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - navigation[0].restoredState!.navigationId - ]; + while (isPopstateNavigationStart(navigation[0])) { + navigation = history[navigation[0].restoredState.navigationId]; } return navigation; diff --git a/packages/router-component-store/src/lib/util-types/non-nullish.ts b/packages/router-component-store/src/lib/util-types/non-nullish.ts new file mode 100644 index 0000000..a29cb1b --- /dev/null +++ b/packages/router-component-store/src/lib/util-types/non-nullish.ts @@ -0,0 +1,40 @@ +/** + * Shallow removal of `null` and `undefined` from the types of all members of a shape. + * + * @example + * interface LaxPerson { + * name?: string; + * address?: Address | null; + * age: number | null; + * } + * type StrictPerson = NonNullish; + * // -> interface { + * // name: string; + * // address: Address; + * // age: number; + * // } + * + * @license + * Copyright 2022 Lars Gyrup Brink Nielsen + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export type NonNullish = { + [TMember in keyof Required]: NonNullable; +}; diff --git a/packages/router-component-store/src/lib/util-types/override.ts b/packages/router-component-store/src/lib/util-types/override.ts new file mode 100644 index 0000000..78eb7b0 --- /dev/null +++ b/packages/router-component-store/src/lib/util-types/override.ts @@ -0,0 +1,47 @@ +/** + * Shallow override of one or more members of a shape. + * + * @example + * interface LaxPerson { + * name?: string; + * address?: Address | null; + * age: number | null; + * } + * type NamedPerson = Override< + * LaxPerson, + * { + * name: string; + * } + * >; + * // -> interface { + * // name: string; + * // address?: Address | null; + * // age: number | null; + * // } + * + * @license + * Copyright 2022 Lars Gyrup Brink Nielsen + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +export type Override> = Omit< + TOriginal, + keyof TOverrides +> & + TOverrides; From 521cde1f1b56883c30ff4d7dae2ece7630b1933f Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:19:32 +0100 Subject: [PATCH 14/25] fix: handle `NavigationCancel` and `NavigationError` --- .../router-history.store.ts | 59 +++++++++++++------ 1 file changed, 40 insertions(+), 19 deletions(-) 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 index 74d0932..23d7417 100644 --- 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 @@ -5,7 +5,13 @@ import { Injectable, Provider, } from '@angular/core'; -import { NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router, +} from '@angular/router'; import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; import { filter, map, Observable, switchMap, take } from 'rxjs'; import { filterRouterEvents } from '../filter-router-event.operator'; @@ -26,6 +32,16 @@ interface RouterHistoryState { type RouterNavigatedSequence = readonly [NavigationStart, NavigationEnd]; type RouterNavigatedHistory = Readonly>; +type RouterSequence = readonly [ + NavigationStart, + NavigationEnd | NavigationCancel | NavigationError +]; + +function isRouterNavigatedSequence( + sequence: RouterSequence +): sequence is RouterNavigatedSequence { + return sequence[1] instanceof NavigationEnd; +} /** * Provide and initialize the `RouterHistoryStore`. @@ -55,34 +71,39 @@ export class RouterHistoryStore extends ComponentStore { (maxNavigatedId): maxNavigatedId is number => maxNavigatedId !== undefined ) ); + + /** + * All router events. + */ + #routerEvents = this.select(this.#router.events, (events) => events); /** - * All `NavigationEnd` events. + * All router events concluding a navigation. */ - #navigationEnd$: Observable = this.#router.events.pipe( - filterRouterEvents(NavigationEnd) + #navigationResult$: Observable< + NavigationEnd | NavigationCancel | NavigationError + > = this.#routerEvents.pipe( + filterRouterEvents(NavigationEnd, NavigationCancel, NavigationError) ); /** - * All `NavigationStart` events. + * All router sequences. */ - #navigationStart$: Observable = this.#router.events.pipe( - filterRouterEvents(NavigationStart) + #routerSequence$: Observable = this.#routerEvents.pipe( + filterRouterEvents(NavigationStart), + switchMap((navigationStart) => + this.#navigationResult$.pipe( + filter( + (navigationResult) => navigationResult.id === navigationStart.id + ), + take(1), + map((navigationResult) => [navigationStart, navigationResult] as const) + ) + ) ); /** * All router navigated sequences, that is `NavigationStart` followed by `NavigationEnd`. */ #routerNavigated$: Observable = - this.#navigationStart$.pipe( - switchMap((navigationStart) => - this.#navigationEnd$.pipe( - filter((navigationEnd) => navigationEnd.id === navigationStart.id), - take(1), - map( - (navigationEnd) => - [navigationStart, navigationEnd] as RouterNavigatedSequence - ) - ) - ) - ); + this.#routerSequence$.pipe(filter(isRouterNavigatedSequence)); /** * The most recent completed navigation. From a2f5bacd15cd9f8e2f4eea03d989fed3eb0ebe9c Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:27:09 +0100 Subject: [PATCH 15/25] refactor: improve type annotations and inline documentation --- .../router-history.store.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) 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 index 23d7417..91723f6 100644 --- 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 @@ -6,6 +6,7 @@ import { Provider, } from '@angular/core'; import { + Event as NgRouterEvent, NavigationCancel, NavigationEnd, NavigationError, @@ -75,9 +76,12 @@ export class RouterHistoryStore extends ComponentStore { /** * All router events. */ - #routerEvents = this.select(this.#router.events, (events) => events); + #routerEvents: Observable = this.select( + this.#router.events, + (events) => events + ); /** - * All router events concluding a navigation. + * All router events concluding a router sequence. */ #navigationResult$: Observable< NavigationEnd | NavigationCancel | NavigationError @@ -108,15 +112,15 @@ export class RouterHistoryStore extends ComponentStore { /** * The most recent completed navigation. */ - #latestRouterNavigatedSequence$ = this.select( - this.#maxNavigatedId$, - this.#history$, - (maxNavigatedId, history) => - history[maxNavigatedId] as RouterNavigatedSequence, - { - debounce: true, - } - ); + #latestRouterNavigatedSequence$: Observable = + this.select( + this.#maxNavigatedId$, + this.#history$, + (maxNavigatedId, history) => history[maxNavigatedId], + { + debounce: true, + } + ); /** * The current URL. @@ -139,7 +143,7 @@ export class RouterHistoryStore extends ComponentStore { return undefined; } - const [sourceNavigationStart] = this.#getNavigationSource( + const [sourceNavigationStart] = this.#findNavigatedSource( maxNavigatedId, history ); @@ -149,7 +153,7 @@ export class RouterHistoryStore extends ComponentStore { } const previousNavigationId = sourceNavigationStart.id - 1; - const [, previousNavigationEnd] = this.#getNavigationSource( + const [, previousNavigationEnd] = this.#findNavigatedSource( previousNavigationId, history ); @@ -189,16 +193,16 @@ export class RouterHistoryStore extends ComponentStore { ); /** - * Search the specified navigation history to find the source of the - * specified navigation event. + * Search the specified history to find the source of the router navigated + * sequence. * * This takes `popstate` navigation events into account. * * @param navigationId The ID of the navigation to trace. * @param history The navigation history to search. - * @returns The source navigation. + * @returns The source router navigated sequence. */ - #getNavigationSource( + #findNavigatedSource( navigationId: number, history: RouterNavigatedHistory ): RouterNavigatedSequence { From fa809ffbbf2aa6cfa009c23017da331586dece9c Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:27:26 +0100 Subject: [PATCH 16/25] refactor: remove unnecessary selector debouncing --- .../src/lib/router-history-store/router-history.store.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 index 91723f6..0653401 100644 --- 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 @@ -116,10 +116,7 @@ export class RouterHistoryStore extends ComponentStore { this.select( this.#maxNavigatedId$, this.#history$, - (maxNavigatedId, history) => history[maxNavigatedId], - { - debounce: true, - } + (maxNavigatedId, history) => history[maxNavigatedId] ); /** @@ -159,9 +156,6 @@ export class RouterHistoryStore extends ComponentStore { ); return previousNavigationEnd.urlAfterRedirects; - }, - { - debounce: true, } ); From 654119b5878faf800735c755f3aae48bbad822df Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:32:17 +0100 Subject: [PATCH 17/25] refactor: extract router sequence types and type guard --- .../router-history.store.ts | 16 +++++----------- .../router-history-store/router-sequence.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 packages/router-component-store/src/lib/router-history-store/router-sequence.ts 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 index 0653401..aed266e 100644 --- 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 @@ -17,6 +17,11 @@ import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; import { filter, map, Observable, switchMap, take } from 'rxjs'; import { filterRouterEvents } from '../filter-router-event.operator'; import { isPopstateNavigationStart } from './popstate-navigation-start'; +import { + isRouterNavigatedSequence, + RouterNavigatedSequence, + RouterSequence, +} from './router-sequence'; interface RouterHistoryState { /** @@ -31,18 +36,7 @@ interface RouterHistoryState { readonly maxNavigatedId?: number; } -type RouterNavigatedSequence = readonly [NavigationStart, NavigationEnd]; type RouterNavigatedHistory = Readonly>; -type RouterSequence = readonly [ - NavigationStart, - NavigationEnd | NavigationCancel | NavigationError -]; - -function isRouterNavigatedSequence( - sequence: RouterSequence -): sequence is RouterNavigatedSequence { - return sequence[1] instanceof NavigationEnd; -} /** * Provide and initialize the `RouterHistoryStore`. diff --git a/packages/router-component-store/src/lib/router-history-store/router-sequence.ts b/packages/router-component-store/src/lib/router-history-store/router-sequence.ts new file mode 100644 index 0000000..0c91ecd --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-sequence.ts @@ -0,0 +1,18 @@ +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, +} from '@angular/router'; + +export type RouterNavigatedSequence = readonly [NavigationStart, NavigationEnd]; +export type RouterSequence = readonly [ + NavigationStart, + NavigationEnd | NavigationCancel | NavigationError +]; + +export function isRouterNavigatedSequence( + sequence: RouterSequence +): sequence is RouterNavigatedSequence { + return sequence[1] instanceof NavigationEnd; +} From 16875eabafad1095458f25b355a61f658c7bd19d Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:35:12 +0100 Subject: [PATCH 18/25] refactor: rename `RouterHistory` --- .../src/lib/router-history-store/router-history.store.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 index aed266e..8fcbb45 100644 --- 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 @@ -23,21 +23,20 @@ import { RouterSequence, } from './router-sequence'; +type RouterHistory = Readonly>; interface RouterHistoryState { /** * The history of all router navigated sequences. * * The key is the navigation ID. */ - readonly history: RouterNavigatedHistory; + readonly history: RouterHistory; /** * The ID of the most recent router navigated sequence events. */ readonly maxNavigatedId?: number; } -type RouterNavigatedHistory = Readonly>; - /** * Provide and initialize the `RouterHistoryStore`. * @@ -187,12 +186,12 @@ export class RouterHistoryStore extends ComponentStore { * This takes `popstate` navigation events into account. * * @param navigationId The ID of the navigation to trace. - * @param history The navigation history to search. + * @param history The history to search. * @returns The source router navigated sequence. */ #findNavigatedSource( navigationId: number, - history: RouterNavigatedHistory + history: RouterHistory ): RouterNavigatedSequence { let navigation = history[navigationId]; From caefc589cf72144aed14bb781efc3c2a2ff8b2a3 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:40:39 +0100 Subject: [PATCH 19/25] refactor: rename `RouterHistoryStore##findSourceNavigatedSequence` --- .../src/lib/router-history-store/router-history.store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 8fcbb45..c4fe6d1 100644 --- 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 @@ -133,7 +133,7 @@ export class RouterHistoryStore extends ComponentStore { return undefined; } - const [sourceNavigationStart] = this.#findNavigatedSource( + const [sourceNavigationStart] = this.#findSourceNavigatedSequence( maxNavigatedId, history ); @@ -143,7 +143,7 @@ export class RouterHistoryStore extends ComponentStore { } const previousNavigationId = sourceNavigationStart.id - 1; - const [, previousNavigationEnd] = this.#findNavigatedSource( + const [, previousNavigationEnd] = this.#findSourceNavigatedSequence( previousNavigationId, history ); @@ -189,7 +189,7 @@ export class RouterHistoryStore extends ComponentStore { * @param history The history to search. * @returns The source router navigated sequence. */ - #findNavigatedSource( + #findSourceNavigatedSequence( navigationId: number, history: RouterHistory ): RouterNavigatedSequence { From da9f557621c4d40ad5459cc188423b7f28c30bac Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:43:25 +0100 Subject: [PATCH 20/25] docs: describe `RouterHistory` --- .../src/lib/router-history-store/router-history.store.ts | 5 +++++ 1 file changed, 5 insertions(+) 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 index c4fe6d1..0177951 100644 --- 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 @@ -23,6 +23,11 @@ import { RouterSequence, } from './router-sequence'; +/** + * A history of router navigated sequences. + * + * The key is the navigation ID. + */ type RouterHistory = Readonly>; interface RouterHistoryState { /** From de60779ce82d08adb5cb4d9980ebd85c99174257 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Thu, 5 Jan 2023 01:56:06 +0100 Subject: [PATCH 21/25] feat: add navigation effects --- .../router-history.store.spec.ts | 6 ++--- .../router-history.store.ts | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts index e960f1b..cd4d8d1 100644 --- a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -1,4 +1,4 @@ -import { AsyncPipe, Location, NgIf } from '@angular/common'; +import { AsyncPipe, NgIf } from '@angular/common'; import { Component, inject, NgZone } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; @@ -39,13 +39,11 @@ function createTestComponent(name: string, selector: string) { `, }) class TestAppComponent { - #location = inject(Location); - protected routerHistory = inject(RouterHistoryStore); onBack(event: MouseEvent) { event.preventDefault(); - this.#location.back(); + this.routerHistory.onNavigateBack(); } } 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 index 0177951..7ad7381 100644 --- 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 @@ -1,3 +1,4 @@ +import { Location as NgLocation } from '@angular/common'; import { APP_INITIALIZER, FactoryProvider, @@ -14,7 +15,7 @@ import { Router, } from '@angular/router'; import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; -import { filter, map, Observable, switchMap, take } from 'rxjs'; +import { filter, map, Observable, pipe, switchMap, take, tap } from 'rxjs'; import { filterRouterEvents } from '../filter-router-event.operator'; import { isPopstateNavigationStart } from './popstate-navigation-start'; import { @@ -57,6 +58,7 @@ export function provideRouterHistoryStore(): Provider[] { @Injectable() export class RouterHistoryStore extends ComponentStore { + #location = inject(NgLocation); #router = inject(Router); /** @@ -163,6 +165,24 @@ export class RouterHistoryStore extends ComponentStore { this.#addRouterNavigatedSequence(this.#routerNavigated$); } + /** + * Navigate back in the browser history. + * + * @remarks + * This is only available when the browser history contains a back entry. + */ + onNavigateBack = this.effect(pipe(tap(() => this.#location.back()))); + + /** + * Navigate forward in the browser history. + * + * @remarks + * This is only available when the browser history contains a forward entry. + */ + onNavigateForward = this.effect( + pipe(tap(() => this.#location.forward())) + ); + /** * Add a router navigated sequence to the router navigated history. */ From 6508ce1937f8a3e9675f297cddeb9b10ff9b0d19 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Tue, 23 May 2023 00:02:09 +0200 Subject: [PATCH 22/25] feat: provide `RouterHistoryStore` as `EnvironmentProviders` --- .../src/lib/router-history-store/router-history.store.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 7ad7381..dcdb442 100644 --- 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 @@ -1,10 +1,11 @@ import { Location as NgLocation } from '@angular/common'; import { APP_INITIALIZER, + EnvironmentProviders, FactoryProvider, inject, Injectable, - Provider, + makeEnvironmentProviders, } from '@angular/core'; import { Event as NgRouterEvent, @@ -49,11 +50,11 @@ interface RouterHistoryState { * @remarks * Must be provided by the root injector to capture all navigation events. */ -export function provideRouterHistoryStore(): Provider[] { - return [ +export function provideRouterHistoryStore(): EnvironmentProviders { + return makeEnvironmentProviders([ provideComponentStore(RouterHistoryStore), routerHistoryStoreInitializer, - ]; + ]); } @Injectable() From 23295c36d6e7da3eb62393736ff5619aeade7647 Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Tue, 23 May 2023 00:05:58 +0200 Subject: [PATCH 23/25] refactor: rename `RouterHistoryStore#routerEvents` to `RouterHistoryStore#routerEvent$` --- .../src/lib/router-history-store/router-history.store.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index dcdb442..68a4edf 100644 --- 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 @@ -77,7 +77,7 @@ export class RouterHistoryStore extends ComponentStore { /** * All router events. */ - #routerEvents: Observable = this.select( + #routerEvent$: Observable = this.select( this.#router.events, (events) => events ); @@ -86,13 +86,13 @@ export class RouterHistoryStore extends ComponentStore { */ #navigationResult$: Observable< NavigationEnd | NavigationCancel | NavigationError - > = this.#routerEvents.pipe( + > = this.#routerEvent$.pipe( filterRouterEvents(NavigationEnd, NavigationCancel, NavigationError) ); /** * All router sequences. */ - #routerSequence$: Observable = this.#routerEvents.pipe( + #routerSequence$: Observable = this.#routerEvent$.pipe( filterRouterEvents(NavigationStart), switchMap((navigationStart) => this.#navigationResult$.pipe( From b284f43c423f8a9259ae118329880726569f6f8e Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Tue, 23 May 2023 00:20:02 +0200 Subject: [PATCH 24/25] refactor: remove Component Store lifecycle support --- .../src/lib/router-history-store/router-history.store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 68a4edf..78499df 100644 --- 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 @@ -15,7 +15,7 @@ import { NavigationStart, Router, } from '@angular/router'; -import { ComponentStore, provideComponentStore } from '@ngrx/component-store'; +import { ComponentStore } from '@ngrx/component-store'; import { filter, map, Observable, pipe, switchMap, take, tap } from 'rxjs'; import { filterRouterEvents } from '../filter-router-event.operator'; import { isPopstateNavigationStart } from './popstate-navigation-start'; @@ -52,7 +52,7 @@ interface RouterHistoryState { */ export function provideRouterHistoryStore(): EnvironmentProviders { return makeEnvironmentProviders([ - provideComponentStore(RouterHistoryStore), + RouterHistoryStore, routerHistoryStoreInitializer, ]); } From a3bdc1d96eff922ea78294fb212bd29d707e51dc Mon Sep 17 00:00:00 2001 From: Lars Gyrup Brink Nielsen Date: Tue, 23 May 2023 01:29:02 +0200 Subject: [PATCH 25/25] feat: add WIP `RouterHistoryStore#nextUrl$` property --- .../router-history-service-in-angular.spec.ts | 77 ++++++++++ .../router-history.store.spec.ts | 140 +++++++++++++++++- .../router-history.store.ts | 32 ++++ 3 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/router-component-store/src/lib/router-history-store/router-history-service-in-angular.spec.ts diff --git a/packages/router-component-store/src/lib/router-history-store/router-history-service-in-angular.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history-service-in-angular.spec.ts new file mode 100644 index 0000000..24bc636 --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history-service-in-angular.spec.ts @@ -0,0 +1,77 @@ +import { + Event as NgRouterEvent, + NavigationEnd, + NavigationStart, +} from '@angular/router'; + +export const routerEvents: readonly NgRouterEvent[] = [ + // 1. Navigate to the root path ‘/’, which redirects me to the homepage + // Current: Home + // Previous: None + // Next: None + new NavigationStart(1, '/', 'imperative', null), + new NavigationEnd(1, '/', '/home'), + + // 2. Click a menu link to navigate to the About page + // Current: About + // Previous: Home + // Next: None + new NavigationStart(2, '/about', 'imperative', null), + new NavigationEnd(2, '/about', '/about'), + + // 3. Click a menu link to navigate to the Company page + // Current: Company + // Previous About + // Next: None + new NavigationStart(3, '/company', 'imperative', null), + new NavigationEnd(3, '/company', '/company'), + + // 4. Click the back button + // Current: About + // Previous: Home + // Next: Company + new NavigationStart(4, '/about', 'popstate', { navigationId: 2 }), + new NavigationEnd(4, '/about', '/about'), + + // 5. Click a menu link to navigate to the Products page + // Current: Products + // Previous: About + // Next: None + new NavigationStart(5, '/products', 'imperative', null), + new NavigationEnd(5, '/products', '/products'), + + // 6. Click a menu link to navigate to the Home page + // Current: Home + // Previous: Products + // Next: None + new NavigationStart(6, '/home', 'imperative', null), + new NavigationEnd(6, '/home', '/home'), + + // 7. Click a menu link to navigate to the About page + // Current: About + // Previous: Home + // Next: None + new NavigationStart(7, '/about', 'imperative', null), + new NavigationEnd(7, '/about', '/about'), + + // 8. Click the back button + // Current: Home + // Previous: Products + // Next: About + new NavigationStart(8, '/home', 'popstate', { navigationId: 6 }), + new NavigationEnd(8, '/home', '/home'), + + // 9. Click the forward button + // Current: About + // Previous: Home + // Next: None + new NavigationStart(9, '/about', 'popstate', { navigationId: 7 }), + new NavigationEnd(9, '/about', '/about'), + + // 10. Click the back button + // Current: Home + // Previous: Products + // Next: About + new NavigationStart(10, '/home', 'popstate', { navigationId: 8 }), + new NavigationEnd(10, '/home', '/home'), +]; diff --git a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts index cd4d8d1..ea8fcd1 100644 --- a/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -30,6 +30,14 @@ function createTestComponent(name: string, selector: string) { >< Back + > Next + Home About Company @@ -45,6 +53,11 @@ class TestAppComponent { event.preventDefault(); this.routerHistory.onNavigateBack(); } + + onNext(event: MouseEvent) { + event.preventDefault(); + this.routerHistory.onNavigateForward(); + } } describe(RouterHistoryStore.name, () => { @@ -98,55 +111,176 @@ describe(RouterHistoryStore.name, () => { } it('the URLs behave like the History API when navigating using links', async () => { - expect.assertions(2); + expect.assertions(3); const { click, routerHistory } = await setup(); // At Home + // Previous: None + // Next: None await click('#about-link'); // At About + // Previous: Home + // Next: None await click('#company-link'); // At Company + // Previous: About + // Next: None await click('#products-link'); // At Products + // Previous: Company + // Next: None expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products'); expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/company'); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined); }); it('the URLs behave like the History API when navigating back', async () => { - expect.assertions(2); + expect.assertions(3); + + const { click, routerHistory } = await setup(); + + // At Home + // Previous: None + // Next: None + await click('#about-link'); + // At About + // Previous: Home + // Next: None + await click('#company-link'); + // At Company + // Previous: About + // Next: None + await click('#back-link'); + // At About + // Previous: Home + // Next: Company + + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home'); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/company'); + }); + + it('the URLs behave like the History API when navigating back twice', async () => { + expect.assertions(3); + + const { click, routerHistory } = await setup(); + + // At Home + // Previous: None + // Next: None + await click('#about-link'); + // At About + // Previous: Home + // Next: None + await click('#company-link'); + // At Company + // Previous: About + // Next: None + await click('#back-link'); + // At About + // Previous: Home + // Next: Company + await click('#back-link'); + // At Home + // Previous: None + // Next: About + + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/home'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe(undefined); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/about'); + }); + + it('the URLs behave like the History API when navigating back twice then forward', async () => { + expect.assertions(3); const { click, routerHistory } = await setup(); // At Home + // Previous: None + // Next: None await click('#about-link'); // At About + // Previous: Home + // Next: None await click('#company-link'); // At Company + // Previous: About + // Next: None await click('#back-link'); // At About + // Previous: Home + // Next: Company + await click('#back-link'); + // At Home + // Previous: None + // Next: About + await click('#forward-link'); + // At About + // Previous: Home + // Next: Company expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/about'); expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/home'); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe('/company'); }); it('the URLs behave like the History API when navigating back then using links', async () => { - expect.assertions(2); + expect.assertions(3); const { click, routerHistory } = await setup(); // At Home + // Previous: None + // Next: None await click('#about-link'); // At About + // Previous: Home + // Next: None await click('#company-link'); // At Company + // Previous: About + // Next: None await click('#back-link'); // At About + // Previous: Home + // Next: Company await click('#products-link'); // At Products + // Previous: About + // Next: None expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/products'); expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about'); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined); + }); + + it('the URLs behave like the History API when navigating back then forward', async () => { + expect.assertions(3); + + const { click, routerHistory } = await setup(); + + // At Home + await click('#about-link'); + // At About + // Previous: Home + // Next: None + await click('#company-link'); + // At Company + // Previous: About + // Next: None + await click('#back-link'); + // At About + // Previous: Home + // Next: Company + await click('#forward-link'); + // At Company + // Previous: About + // Next: None + + expect(await firstValueFrom(routerHistory.currentUrl$)).toBe('/company'); + expect(await firstValueFrom(routerHistory.previousUrl$)).toBe('/about'); + expect(await firstValueFrom(routerHistory.nextUrl$)).toBe(undefined); }); }); 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 index 78499df..adb89fa 100644 --- 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 @@ -127,6 +127,38 @@ export class RouterHistoryStore extends ComponentStore { this.#latestRouterNavigatedSequence$, ([, navigationEnd]) => navigationEnd.urlAfterRedirects ); + /** + * The next URL when taking `popstate` events into account. + * + * `undefined` is emitted when the current navigation is the last in the + * navigation history. + */ + nextUrl$: Observable = this.select( + this.#history$, + this.#maxNavigatedId$, + (history, maxNavigatedId) => { + if (maxNavigatedId === 1) { + return undefined; + } + + const [sourceNavigationStart] = this.#findSourceNavigatedSequence( + maxNavigatedId, + history + ); + + if (sourceNavigationStart.id === maxNavigatedId) { + return undefined; + } + + const nextNavigationId = sourceNavigationStart.id + 1; + const [, nextNavigationEnd] = this.#findSourceNavigatedSequence( + nextNavigationId, + history + ); + + return nextNavigationEnd.urlAfterRedirects; + } + ); /** * The previous URL when taking `popstate` events into account. *