aurelia/aurelia

View on GitHub
packages/router/src/browser-viewer-store.ts

Summary

Maintainability
D
2 days
Test Coverage
F
51%
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);
  }
}