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-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 new file mode 100644 index 0000000..ea8fcd1 --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.spec.ts @@ -0,0 +1,286 @@ +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'; +import { Router, RouterLink, RouterOutlet } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { firstValueFrom } from 'rxjs'; +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 + + > Next + + Home + About + Company + Products + + + `, +}) +class TestAppComponent { + protected routerHistory = inject(RouterHistoryStore); + + onBack(event: MouseEvent) { + event.preventDefault(); + this.routerHistory.onNavigateBack(); + } + + onNext(event: MouseEvent) { + event.preventDefault(); + this.routerHistory.onNavigateForward(); + } +} + +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 using links', 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('#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(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(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 new file mode 100644 index 0000000..adb89fa --- /dev/null +++ b/packages/router-component-store/src/lib/router-history-store/router-history.store.ts @@ -0,0 +1,286 @@ +import { Location as NgLocation } from '@angular/common'; +import { + APP_INITIALIZER, + EnvironmentProviders, + FactoryProvider, + inject, + Injectable, + makeEnvironmentProviders, +} from '@angular/core'; +import { + Event as NgRouterEvent, + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router, +} from '@angular/router'; +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'; +import { + isRouterNavigatedSequence, + RouterNavigatedSequence, + RouterSequence, +} from './router-sequence'; + +/** + * A history of router navigated sequences. + * + * The key is the navigation ID. + */ +type RouterHistory = Readonly>; +interface RouterHistoryState { + /** + * The history of all router navigated sequences. + * + * The key is the navigation ID. + */ + readonly history: RouterHistory; + /** + * The ID of the most recent router navigated sequence events. + */ + readonly maxNavigatedId?: number; +} + +/** + * Provide and initialize the `RouterHistoryStore`. + * + * @remarks + * Must be provided by the root injector to capture all navigation events. + */ +export function provideRouterHistoryStore(): EnvironmentProviders { + return makeEnvironmentProviders([ + RouterHistoryStore, + routerHistoryStoreInitializer, + ]); +} + +@Injectable() +export class RouterHistoryStore extends ComponentStore { + #location = inject(NgLocation); + #router = inject(Router); + + /** + * The history of all navigations. + */ + #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 router events. + */ + #routerEvent$: Observable = this.select( + this.#router.events, + (events) => events + ); + /** + * All router events concluding a router sequence. + */ + #navigationResult$: Observable< + NavigationEnd | NavigationCancel | NavigationError + > = this.#routerEvent$.pipe( + filterRouterEvents(NavigationEnd, NavigationCancel, NavigationError) + ); + /** + * All router sequences. + */ + #routerSequence$: Observable = this.#routerEvent$.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.#routerSequence$.pipe(filter(isRouterNavigatedSequence)); + + /** + * The most recent completed navigation. + */ + #latestRouterNavigatedSequence$: Observable = + this.select( + this.#maxNavigatedId$, + this.#history$, + (maxNavigatedId, history) => history[maxNavigatedId] + ); + + /** + * The current URL. + */ + currentUrl$: Observable = this.select( + 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. + * + * `undefined` is emitted when the current navigation is the first in the + * navigation history. + */ + previousUrl$: Observable = this.select( + this.#history$, + this.#maxNavigatedId$, + (history, maxNavigatedId) => { + if (maxNavigatedId === 1) { + return undefined; + } + + const [sourceNavigationStart] = this.#findSourceNavigatedSequence( + maxNavigatedId, + history + ); + + if (sourceNavigationStart.id === 1) { + return undefined; + } + + const previousNavigationId = sourceNavigationStart.id - 1; + const [, previousNavigationEnd] = this.#findSourceNavigatedSequence( + previousNavigationId, + history + ); + + return previousNavigationEnd.urlAfterRedirects; + } + ); + + constructor() { + super(initialState); + + 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. + */ + #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, + }; + } + ); + + /** + * 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 history to search. + * @returns The source router navigated sequence. + */ + #findSourceNavigatedSequence( + navigationId: number, + history: RouterHistory + ): RouterNavigatedSequence { + let navigation = history[navigationId]; + + while (isPopstateNavigationStart(navigation[0])) { + navigation = history[navigation[0].restoredState.navigationId]; + } + + return navigation; + } +} + +/** + * 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, +}; 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; +} 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;