UNPKG

@optimizely/nuclear-router

Version:
353 lines (289 loc) 11.3 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); /** * @module */ /** * Input to Client Decision Engine * * @typedef Route * @property {String|RegExp} match * @property {RouterHandler[]} handlers */ /** * @callback RouteHandler * @param {Context} ctx * @param {Function} nextFn */ /** * @typedef {"push" | "replace" | "pop"} Mode */ var _objectAssign = require('object-assign'); var _objectAssign2 = _interopRequireDefault(_objectAssign); var _Context = require('./Context'); var _Context2 = _interopRequireDefault(_Context); var _fns = require('./fns'); var _fns2 = _interopRequireDefault(_fns); var _Route = require('./Route'); var _Route2 = _interopRequireDefault(_Route); var _WindowEnv = require('./WindowEnv'); var _WindowEnv2 = _interopRequireDefault(_WindowEnv); var _HistoryEnv = require('./HistoryEnv'); var _HistoryEnv2 = _interopRequireDefault(_HistoryEnv); var _DocumentEnv = require('./DocumentEnv'); var _DocumentEnv2 = _interopRequireDefault(_DocumentEnv); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } var Router = function () { function Router(opts) { _classCallCheck(this, Router); this.opts = opts || {}; this.opts = (0, _objectAssign2.default)({ pushstate: true, base: '' }, opts); this.onRouteStart = this.opts.onRouteStart; this.onRouteComplete = this.opts.onRouteComplete; this.__onpopstate = this.__onpopstate.bind(this); this.__filterPopstateEvent = this.opts.filterPopstateEvent || function () { return true; }; } _createClass(Router, [{ key: 'initialize', value: function initialize() { this.setInitialState(); _WindowEnv2.default.addEventListener('popstate', this.__onpopstate); } }, { key: 'reset', value: function reset() { this.setInitialState(); _WindowEnv2.default.removeEventListener('popstate', this.__onpopstate); } }, { key: 'setInitialState', value: function setInitialState() { this.__fromPath = null; this.__routes = []; this.__currentCanonicalPath = null; this.__catchallPath = null; this.__dispatchId = 0; this.__startTime = null; this.__shouldHandlePopstateEvents = true; } /** * Registers Routes for the application * * ``` * var router = new Router(reactor) * router.registerRoutes([ * { * match: '/foo', * handlers: [ * (ctx, next) => { * fetchDataForFoo() * next() * } * ] * } * ``` * * @param {Route[]} routes */ }, { key: 'registerRoutes', value: function registerRoutes(routes) { var _this = this; if (!Array.isArray(routes)) { throw new Error('Router#registerRoutes must be passed an array of Routes'); } routes.forEach(function (route) { _this.__routes.push(new _Route2.default(route)); }); } /** * @param {String} path */ }, { key: 'registerCatchallPath', value: function registerCatchallPath(path) { this.__catchallPath = path; } /** * @param {String} canonicalPath * @param {Mode} mode */ }, { key: 'go', value: function go(canonicalPath) { var mode = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'push'; this.__dispatch(canonicalPath, mode); } /** * @param {String} canonicalPath */ }, { key: 'replace', value: function replace(canonicalPath) { this.__dispatch(canonicalPath, 'replace'); } }, { key: 'catchall', value: function catchall() { if (typeof this.__catchallPath === 'string') { _WindowEnv2.default.navigate(this.__catchallPath); } } /** * @param {function} fn - Async function to execute w/o popstate listener */ }, { key: 'executeWithoutPopstateListener', value: function executeWithoutPopstateListener(fn) { var _this2 = this; this.__shouldHandlePopstateEvents = false; // Execute the function, then re-enable the popstate listener return fn().then(function (result) { _this2.__shouldHandlePopstateEvents = true; return Promise.resolve(result); }, function (error) { _this2.__shouldHandlePopstateEvents = true; return Promise.reject(error); }); } /** * Execute a single route, which consists of: * - Updating the url via pushstate/replacestate (if specified) * - Running the route's handlers * - Invoking the onRouteStart and onRouteComplete handlers (if specified) * @param {String} path * @param {String} canonicalPath * @param {Mode} mode * @param {Object} routeData * @param {Router} routeData.route * @param {Object} routeData.params * @private */ }, { key: '__executeRoute', value: function __executeRoute(path, canonicalPath, mode, _ref) { var _this3 = this; var route = _ref.route, params = _ref.params; var title = _DocumentEnv2.default.getTitle(); var ctx = new _Context2.default({ canonicalPath: canonicalPath, path: path, title: title, params: params, dispatchId: this.__dispatchId }); if (mode === 'replace') { _HistoryEnv2.default.replaceState.apply(null, ctx.getHistoryArgs()); } else if (mode === 'push') { _HistoryEnv2.default.pushState.apply(null, ctx.getHistoryArgs()); } this.__currentCanonicalPath = canonicalPath; var routeMetadata = route.metadata || {}; if (this.onRouteStart && mode !== 'replace') { this.onRouteStart({ routeMetadata: routeMetadata, fromPath: this.__fromPath || 'PAGE LOAD', startTime: this.__startTime, context: ctx }); } this.__runHandlers(route.handlers, ctx, function () { var startTime = _this3.__startTime; var endTime = _fns2.default.getNow(); var duration = endTime - startTime; var fromPath = _this3.__fromPath || 'PAGE LOAD'; var toPath = canonicalPath; if (_this3.onRouteComplete) { _this3.onRouteComplete({ fromPath: fromPath, toPath: toPath, duration: duration, startTime: startTime, endTime: endTime, routeMetadata: routeMetadata }); } _this3.__fromPath = canonicalPath; }); } }, { key: '__runHandlers', /** * @param {RouterHandler[]} handlers * @param {Context} ctx */ value: function __runHandlers(handlers, ctx, callback) { var _this4 = this; var index = 0; var next = function next() { if (_this4.__dispatchId !== ctx.dispatchId) { return; } var handlerFnOrArray = handlers[index]; index++; // capture handler index in closure since index could be modified by another thread var handlerIndex = index; if (handlerFnOrArray) { if (Array.isArray(handlerFnOrArray)) { var parallelHandlersComplete = 0; // for parallel handlers we use a custom next callback that tracks completion of all handlers in the group var parallelNext = function parallelNext() { parallelHandlersComplete++; if (parallelHandlersComplete === handlerFnOrArray.length) { next(); // all grouped parallel handlers complete, so invoke the next top-level handler or handler array } }; // execute all handlers in parallel group handlerFnOrArray.map(function (handlerFn) { return handlerFn(ctx, parallelNext); }); } else { handlerFnOrArray(ctx, next); if (callback && handlerIndex === handlers.length && _this4.__dispatchId === ctx.dispatchId) { callback(); } } } }; next(); } /** * @param {String} canonicalPath * @param {Mode} mode */ }, { key: '__dispatch', value: function __dispatch(canonicalPath) { var _this5 = this; var mode = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'push'; this.__dispatchId++; if (mode !== 'replace') { this.__startTime = _fns2.default.getNow(); } var path = _fns2.default.extractPath(this.opts.base, canonicalPath); var matches = _fns2.default.matchRoute(this.__routes, path); _fns2.default.filterMatches(matches).then(function (match) { return _this5.__executeRoute(path, canonicalPath, mode, match); }, function () { return _this5.catchall(); }); } }, { key: '__onpopstate', value: function __onpopstate(e) { if (e.state && this.__shouldHandlePopstateEvents) { // Make sure this popstate event passes the filter fn and wasn't triggered // as a result of the currently in progress dispatch. if (this.__filterPopstateEvent(e) && e.state.nuclearDispatchId !== this.__dispatchId) { this.__dispatch(e.state.path, 'pop'); } } } }]); return Router; }(); exports.default = Router; module.exports = exports['default'];