packages/router/src/browser-viewer-store.ts
import { EventAggregator, IEventAggregator, resolve } from '@aurelia/kernel';
import { IWindow, IHistory, ILocation, IPlatform } from '@aurelia/runtime-html';
import { INavigatorState, INavigatorStore, INavigatorViewer, INavigatorViewerOptions } from './navigator';
import { QueueTask, TaskQueue } from './utilities/task-queue';
import { ErrorNames, createMappedError } from './errors';
/**
* @internal
*/
export interface IBrowserViewerStoreOptions extends INavigatorViewerOptions {
/**
* Whether the hash part of the Location URL should be used for state. If false,
* the Location pathname will be used instead (sometimes referred to as "popstate").
*/
useUrlFragmentHash?: boolean;
}
/**
* Viewer and store layers on top of the browser. The viewer part is for getting
* and setting a state (location) indicator and the store part is for storing
* and retrieving historical states (locations). In the browser, the Location
* is the viewer and the History API provides the store.
*
* All mutating actions towards the viewer and store are added as awaitable tasks
* in a queue.
*
* Events are fired when the current state (location) changes, either through
* direct change (manually altering the Location) or movement to a historical
* state.
*
* All interaction with the browser's Location and History is performed through
* these layers.
*
* @internal
*/
export class BrowserViewerStore implements INavigatorStore, INavigatorViewer, EventListenerObject {
/**
* Limit the number of executed actions within the same RAF (due to browser limitation).
*/
public allowedExecutionCostWithinTick: number = 2;
/**
* State changes that have been triggered but not yet processed.
*/
private readonly pendingCalls: TaskQueue<IAction> = new TaskQueue<IAction>();
/**
* Whether the BrowserViewerStore is started or not.
*/
private isActive: boolean = false;
private options: IBrowserViewerStoreOptions = {
useUrlFragmentHash: true,
};
/**
* A "forwarded state" that's used to decide whether the browser's popstate
* event should fire a change state event or not. Used by 'go' method and
* its 'suppressEvent' option.
*/
private forwardedState: IForwardedState = { eventTask: null, suppressPopstate: false };
private readonly platform: IPlatform = resolve(IPlatform);
private readonly window: IWindow = resolve(IWindow);
private readonly history: IHistory = resolve(IHistory);
private readonly location: ILocation = resolve(ILocation);
private readonly ea: EventAggregator = resolve(IEventAggregator);
public start(options: IBrowserViewerStoreOptions): void {
if (this.isActive) {
throw createMappedError(ErrorNames.browser_viewer_store_already_started);
}
this.isActive = true;
if (options.useUrlFragmentHash != void 0) {
this.options.useUrlFragmentHash = options.useUrlFragmentHash;
}
this.pendingCalls.start({ platform: this.platform, allowedExecutionCostWithinTick: this.allowedExecutionCostWithinTick });
this.window.addEventListener('popstate', this);
}
public stop(): void {
if (!this.isActive) {
throw createMappedError(ErrorNames.browser_viewer_store_not_started);
}
this.window.removeEventListener('popstate', this);
this.pendingCalls.stop();
this.options = { useUrlFragmentHash: true };
this.isActive = false;
}
public get length(): number {
return this.history.length;
}
/**
* The stored state for the current state/location.
*/
public get state(): Record<string, unknown> {
return this.history.state as Record<string, unknown>;
}
/**
* Get the viewer's (browser Location) current state/location (URL).
*/
public get viewerState(): NavigatorViewerState {
const { pathname, search, hash } = this.location;
const instruction = (this.options.useUrlFragmentHash ?? false)
? hash.slice(1)
: `${pathname}${search}`;
const fragment = (this.options.useUrlFragmentHash ?? false)
? (hash.slice(1).includes('#') ? hash.slice(hash.slice(1).indexOf('#', 1)) : '')
: hash.slice(1);
return new NavigatorViewerState(
pathname,
search.slice(1),
fragment,
instruction,
);
}
/**
* Enqueue an awaitable 'go' task that navigates delta amount of steps
* back or forward in the states history.
*
* @param delta - The amount of steps, positive or negative, to move in the states history
* @param suppressEvent - If true, no state change event is fired when the go task is executed
*/
public async go(delta: number, suppressEvent: boolean = false): Promise<boolean | void> {
const doneTask: QueueTask<IAction> = this.pendingCalls.createQueueTask((task: QueueTask<IAction>) => task.resolve(), 1);
this.pendingCalls.enqueue([
(task: QueueTask<IAction>) => {
const eventTask: QueueTask<IAction> = doneTask;
const suppressPopstate: boolean = suppressEvent;
// Set the "forwarded state" that decides whether the browser's popstate event
// should fire a change state event or not
this.forwardState({ eventTask, suppressPopstate });
task.resolve();
},
(task: QueueTask<IAction>) => {
const history: History = this.history;
const steps: number = delta;
history.go(steps);
task.resolve();
},
], [0, 1]);
return doneTask.wait();
}
/**
* Enqueue an awaitable 'push state' task that pushes a state after the current
* historical state. Any pre-existing historical states after the current are
* discarded before the push.
*
* @param state - The state to push
*/
public async pushNavigatorState(state: INavigatorState): Promise<boolean | void> {
const { title, path } = state.navigations[state.navigationIndex];
const fragment = this.options.useUrlFragmentHash ? '#/' : '';
return this.pendingCalls.enqueue(
(task: QueueTask<IAction>) => {
const history: History = this.history;
const data: INavigatorState = state;
const titleOrEmpty: string = title || '';
const url: string = `${fragment}${path}`;
try {
history.pushState(data, titleOrEmpty, url);
this.setTitle(titleOrEmpty);
} catch (err) {
const clean = this.tryCleanState(data, 'push', err as Error);
history.pushState(clean, titleOrEmpty, url);
this.setTitle(titleOrEmpty);
}
task.resolve();
}, 1).wait();
}
/**
* Enqueue an awaitable 'replace state' task that replace the current historical
* state with the provided state.
*
* @param state - The state to replace with
*/
public async replaceNavigatorState(state: INavigatorState, title?: string, path?: string): Promise<boolean | void> {
// const { title, path } = state.currentEntry;
const lastNavigation = state.navigations[state.navigationIndex];
title ??= lastNavigation.title;
path ??= lastNavigation.path;
const fragment = this.options.useUrlFragmentHash ? '#/' : '';
return this.pendingCalls.enqueue(
(task: QueueTask<IAction>) => {
const history: History = this.history;
const data: INavigatorState = state;
const titleOrEmpty: string = title || '';
const url: string = `${fragment}${path}`;
try {
history.replaceState(data, titleOrEmpty, url);
this.setTitle(titleOrEmpty);
} catch (err) {
const clean = this.tryCleanState(data, 'replace', err as Error);
history.replaceState(clean, titleOrEmpty, url);
this.setTitle(titleOrEmpty);
}
task.resolve();
}, 1).wait();
}
/**
* Enqueue an awaitable 'pop state' task that pops the last of the historical states.
*/
public async popNavigatorState(): Promise<boolean | void> {
const doneTask: QueueTask<IAction> = this.pendingCalls.createQueueTask((task: QueueTask<IAction>) => task.resolve(), 1);
this.pendingCalls.enqueue(
async (task: QueueTask<IAction>): Promise<void> => {
const eventTask: QueueTask<IAction> = doneTask;
await this.popState(eventTask);
task.resolve();
}, 1);
return doneTask.wait();
}
public setTitle(title: string): void {
this.window.document.title = title;
}
/**
* Handle the browsers PopStateEvent
*
* @param event - The browser's PopStateEvent
*/
public handleEvent(e: Event): void {
this.handlePopStateEvent(e as PopStateEvent);
}
/**
* Enqueue an awaitable 'pop state' task when the viewer's state (browser's
* Location) changes.
*
* @param event - The browser's PopStateEvent
*/
private handlePopStateEvent(event: PopStateEvent): void {
const { eventTask, suppressPopstate } = this.forwardedState;
this.forwardedState = { eventTask: null, suppressPopstate: false };
this.pendingCalls.enqueue(
async (task: QueueTask<IAction>) => {
if (!suppressPopstate) {
this.notifySubscribers(event);
}
if (eventTask !== null) {
await eventTask.execute();
}
task.resolve();
}, 1);
}
/**
* Notifies subscribers that the state has changed
*
* @param ev - The browser's popstate event
*/
private notifySubscribers(ev: PopStateEvent): void {
this.ea.publish(
NavigatorStateChangeEvent.eventName,
NavigatorStateChangeEvent.create(this.viewerState, ev, this.history.state as INavigatorState)
);
}
/**
* Pop the last historical state by re-pushing the second to last
* historical state (since browser History doesn't have a popState).
*
* @param doneTask - Task to execute once pop is done
*/
private async popState(doneTask: QueueTask<IAction>): Promise<void> {
await this.go(-1, true);
const state = this.history.state as INavigatorState;
// TODO: Fix browser forward bug after pop on first entry
const lastNavigation = state?.navigations?.[state?.navigationIndex ?? 0];
if (lastNavigation != null && !lastNavigation.firstEntry) {
await this.go(-1, true);
await this.pushNavigatorState(state);
}
await doneTask.execute();
}
/**
* Set the "forwarded state" that decides whether the browser's popstate event
* should fire a change state event or not.
*
* @param state - The forwarded state
*/
private forwardState(state: IForwardedState): void {
this.forwardedState = state;
}
/**
* Tries to clean up the state for pushing or replacing to browser History.
*
* @param data - The state to attempt to clean
* @param type - The type of action, push or replace, that failed
* @param originalError - The origial error when trying to push or replace
*/
private tryCleanState(data: unknown, type: 'push' | 'replace', originalError: Error): unknown {
try {
return JSON.parse(JSON.stringify(data));
} catch (err) {
throw createMappedError(ErrorNames.browser_viewer_store_state_serialization_failed, type, err, originalError);
}
}
}
/**
* The state used when communicating with the navigator viewer.
*/
/**
* @internal
*/
export class NavigatorViewerState {
public constructor(
/**
* The URL (Location) path
*/
public path: string,
/**
* The URL (Location) query
*/
public query: string,
/**
* The URL (Location) hash
*/
public hash: string,
/**
* The navigation instruction
*/
public instruction: string,
) { }
}
/**
* @internal
*/
interface IForwardedState {
eventTask: QueueTask<IAction> | null;
suppressPopstate: boolean;
}
/**
* @internal
*/
interface IAction {
execute(task: QueueTask<IAction>, resolve?: ((value?: void | boolean | PromiseLike<void> | PromiseLike<boolean>) => void) | null | undefined, suppressEvent?: boolean): void;
}
export class NavigatorStateChangeEvent {
public static eventName = 'au:router:navigation-state-change';
public constructor(
public readonly eventName: string,
public readonly viewerState: NavigatorViewerState,
public readonly event: PopStateEvent,
public readonly state: INavigatorState,
) { }
public static create(
viewerState: NavigatorViewerState,
ev: PopStateEvent,
navigatorState: INavigatorState,
): NavigatorStateChangeEvent {
return new NavigatorStateChangeEvent(
NavigatorStateChangeEvent.eventName,
viewerState,
ev,
navigatorState);
}
}