Home Reference Source Test

src/router/Router.js

"use strict";

/**
 * @typedef {function(location: RouteLocation, action: string)} RouterBlockHandler
 *
 */

/**
 * @typedef {function(location: RouteLocation)} HistoryListener
 *
 */

const Route = require("./Route");
const createMemoryHistory = require("../common/history");
const mapComposer = require("../utils/map");
const matchPath = require("../common/matchPath");
const matchRoutes = require("../common/matchRoutes");
let actions = [];

let history;
let _skipRender = false;

/**
 * Router Base
 *
 * @class
 * @extends {Route}
 */
class Router extends Route {
  static initializeHistory({
    initialEntries = null,
    initialIndex = null,
    keyLength = null,
    getUserConfirmation = null
  }) {
    history = createMemoryHistory({
      initialEntries: initialEntries || [], // The initial URLs in the history stack
      initialIndex: initialIndex || 0, // The starting index in the history stack
      keyLength: keyLength || 20, // The length of location.key
      // A function to use to confirm navigation with the user. Required
      // if you return string prompts from transition hooks (see below)
      getUserConfirmation: getUserConfirmation
    });
  }

  static skipRender() {
    _skipRender = true;
  }

  static getHistory() {
    return history;
  }

  static unloadHistory() {
    history = null;
  }
  /**
   * @constructor
   * @param {{ path: string, target: object|null, routes: Array, exact: boolean, isRoot: boolean }} param
   */
  constructor({
    path = "",
    build = null,
    routes = [],
    exact = false,
    isRoot = false,
    to = null
  }) {
    super({ path, build, routes, to });

    if (!history) {
      Router.initializeHistory({
        getUserConfirmation: (blockerFn, callback) => {
          return blockerFn(callback);
        }
      });
    }

    this._historyUnlisten = () => null;
    if (isRoot) {
      this._historyUnlisten = history.listen((location, action) => {
        console.log(`History is changed ${_skipRender}`);
        try {
          if (_skipRender === false) {
            this.onHistoryChange(location, action);
          }
        } catch (e) {
          throw e;
        } finally {
          _skipRender = false;
        }

        // ["pathname","search","hash","state","key"]
        // console.log(JSON.stringify(history.entries.map(entry => entry.pathname)));
      });
    }

    this._isRoot = isRoot;
    this._exact = exact;
    // this._cache = new WeakMap();
    this._unblock = () => null;
  }

  /**
   * @param {HistoryListener} fn
   */
  listen(fn) {
    return history.listen(fn);
  }
  
  /**
   * @return {Object}
   */
  getHistory() {
    return history;
  }

  /**
   * Adds route block handler to history. When history is changed in anywhere
   * then the handler intercepts before history is changed.
   *
   * @param {RouterBlockHandler} fn
   */
  addRouteBlocker(fn) {
    const unblock = history.block((location, action) => callback => {
      unblock();
      fn(location, action, callback);
    });

    return unblock;
  }

  /**
   * Triggered when the current route's parent is another router.
   * 
   * @protected
   * @param {string} action
   */
  onRouteExit(action) {}

  /**
   * @param {{ pathname: string, search: string, state: object }} location
   * @param {Object} action
   */
  onHistoryChange(location, action) {
    this._matches = matchRoutes([this].concat(this._routes), location.pathname);

    this.renderMatches(this._matches, location.state, action);
  }

  /**
   * @protected
   * Removes last entry from history.
   */
  routeRollback() {
    this.getHistory().rollback();
  }

  /**
   *
   * @param {Array<{isExact: boolean,params: object,path: string,url: string}>} matches
   * @param {*} state
   * @param {*} action
   */
  renderMatches(matches, state, action) {
    // console.log("matches : " + JSON.stringify(matches.map(({ match }) => match)));
    matches.some(({ match, route }, index) => {
      if (route !== this && route instanceof Router) {
        console.log("not exact match : " + this);
        // if(index > 0 && this._isRoot)
        this.addChildRouter &&
          actions.push([this.addChildRouter.bind(this), route]);
        // move routes to child router
        route.renderMatches(
          matches.slice(index, matches.length),
          state,
          action
        );

        return true;
      } else if (match.isExact === true) {
        console.log("exact match : " + this + " : " + route.getRedirectto());
        // route has redirection
        if (route.getRedirectto()) {
          actions = [];
          return this.redirectRoute(route, action);
        }

        if (this.onRouteMatch(route, match, state, action)) {
          actions.forEach(item => item[0](item[1]));
        }

        this.onRouterEnter && this.onRouterEnter(action);
        actions = [];

        return true;
      }
    });
  }

  /**
   * Router is activated event handler
   * 
   * @protected
   * @param {?string} [action=null] action
   */
  onRouterEnter(action = null) {
    this.setasActiveRouter(action);
  }

  /**
   * Sets the router statically as active router
   * 
   * @protected
   * @param {string} action
   */
  setasActiveRouter(action) {
    Router.currentRouter &&
      this != Router.currentRouter &&
      Router.currentRouter.onRouterExit &&
      Router.currentRouter.onRouterExit(action);
    Router.currentRouter = this;
  }

  /**
   * Redirects route and removes last route record from history
   * @param {Route} route
   * @param {string} action
   */
  redirectRoute(route, action) {
    // redirection of a route
    this.routeRollback(); // remove last route from history
    this.push(route.getRedirectto()); // and add new route
  }

  /**
   * Route is matched event handler
   *
   * @protected
   * @param {Route} route
   * @param {{isExact: boolean, params: object, path: string, url: string}} match
   * @param {Object} state
   * @param {string} action
   */
  onRouteMatch(route, match, state, action) {
    const view = this.renderRoute(route, match, state);
    if (!view) {
      this.routeRollback();
    }

    return view;
  }

  /**
   * Render route
   * 
   * @param {Route} route
   * @param {RouteMatch} match
   * @param {RouteState} state
   */
  renderRoute(route, match, state) {
    let view = route.build(match, state.routeState || {}, this, state.view);
    state.view = view;

    return view;
  }

  /**
   * Helper method that pushes the route's url to history
   *
   * @param {Route} route
   */
  pushRoute(route) {
    console.log(`Push router ${route}`);
    this.push(route.getUrlPath());
  }

  /**
   * Change history by specified path
   *
   * @param {Object|string} path - Path or matches of the route
   * @param {!Object} [data={}] data
   * @return {Router}
   */
  push(path, data = {}) {
    // this._cache.get(path) ||
    if (path.charAt(0) !== "/") {
      path = this._path.getPath() + "/" + path;
    }

    this.getHistory().push(path, { routeState: { data } });

    return this;
  }

  /**
   * Replaces specified path's state
   *
   * @param {string} path
   * @param {data} data
   */
  replace(path, data) {
    this.getHistory().replace(path, { routeState: data });
  }

  /**
   * Rewinds history
   *
   */
  goBack() {
    this.getHistory().go(-1);
  }

  /**
   * Return last location of history
   * 
   * @return {RouteLocation}
   */
  getLocation() {
    return this.getHistory().location;
  }

  /**
   * @return {Array<string>}
   */
  getHistoryasArray() {
    return history.entries.map(item => item.pathname);
  }

  /**
   * Forwards history
   */
  goForward() {
    this.getHistory().goForward();
  }

  /**
   * Changes route by history index.
   * 
   * @param {number} index
   * @return {boolean}
   */
  go(index) {
    if (this.getHistory().canGo(index)) {
      this.getHistory().index();
      return true;
    }

    return false;
  }

  /**
   * Adds new route
   * 
   * @param {Route} route
   */
  add(route) {
    this._routes.push(route);
  }

  /**
   * Iterates child routes
   *
   * @paramms {function} fn
   * @return {Array}
   */
  map(fn) {
    return this._routes.map(fn);
  }

  /**
   * Unloads the router
   */
  dispose() {
    this._historyUnlisten();
    if (this._isRoot) {
      history.clear();
      history = null;
    }
    this._routes.forEach(route => route.dispose());
    this._routes = null;
    this._historyUnlisten = null;
    this._unblock && this._unblock();
    this._unblock = null;
  }
}

module.exports = Router;