/* jshint -W058 */ 'use strict'; var React = require('react'); var warning = require('./warning'); var invariant = require('invariant'); var canUseDOM = require('can-use-dom'); var LocationActions = require('./actions/LocationActions'); var ImitateBrowserBehavior = require('./behaviors/ImitateBrowserBehavior'); var HashLocation = require('./locations/HashLocation'); var HistoryLocation = require('./locations/HistoryLocation'); var RefreshLocation = require('./locations/RefreshLocation'); var StaticLocation = require('./locations/StaticLocation'); var ScrollHistory = require('./ScrollHistory'); var createRoutesFromReactChildren = require('./createRoutesFromReactChildren'); var isReactChildren = require('./isReactChildren'); var Transition = require('./Transition'); var PropTypes = require('./PropTypes'); var Redirect = require('./Redirect'); var History = require('./History'); var Cancellation = require('./Cancellation'); var Match = require('./Match'); var Route = require('./Route'); var supportsHistory = require('./supportsHistory'); var PathUtils = require('./PathUtils'); /** * The default location for new routers. */ var DEFAULT_LOCATION = canUseDOM ? HashLocation : '/'; /** * The default scroll behavior for new routers. */ var DEFAULT_SCROLL_BEHAVIOR = canUseDOM ? ImitateBrowserBehavior : null; function hasProperties(object, properties) { for (var propertyName in properties) if (properties.hasOwnProperty(propertyName) && object[propertyName] !== properties[propertyName]) return false; return true; } function hasMatch(routes, route, prevParams, nextParams, prevQuery, nextQuery) { return routes.some(function (r) { if (r !== route) return false; var paramNames = route.paramNames; var paramName; // Ensure that all params the route cares about did not change. for (var i = 0, len = paramNames.length; i < len; ++i) { paramName = paramNames[i]; if (nextParams[paramName] !== prevParams[paramName]) return false; } // Ensure the query hasn't changed. return hasProperties(prevQuery, nextQuery) && hasProperties(nextQuery, prevQuery); }); } function addRoutesToNamedRoutes(routes, namedRoutes) { var route; for (var i = 0, len = routes.length; i < len; ++i) { route = routes[i]; if (route.name) { invariant(namedRoutes[route.name] == null, 'You may not have more than one route named "%s"', route.name); namedRoutes[route.name] = route; } if (route.childRoutes) addRoutesToNamedRoutes(route.childRoutes, namedRoutes); } } function routeIsActive(activeRoutes, routeName) { return activeRoutes.some(function (route) { return route.name === routeName; }); } function paramsAreActive(activeParams, params) { for (var property in params) if (String(activeParams[property]) !== String(params[property])) return false; return true; } function queryIsActive(activeQuery, query) { for (var property in query) if (String(activeQuery[property]) !== String(query[property])) return false; return true; } /** * Creates and returns a new router using the given options. A router * is a ReactComponent class that knows how to react to changes in the * URL and keep the contents of the page in sync. * * Options may be any of the following: * * - routes (required) The route config * - location The location to use. Defaults to HashLocation when * the DOM is available, "/" otherwise * - scrollBehavior The scroll behavior to use. Defaults to ImitateBrowserBehavior * when the DOM is available, null otherwise * - onError A function that is used to handle errors * - onAbort A function that is used to handle aborted transitions * * When rendering in a server-side environment, the location should simply * be the URL path that was used in the request, including the query string. */ function createRouter(options) { options = options || {}; if (isReactChildren(options)) options = { routes: options }; var mountedComponents = []; var location = options.location || DEFAULT_LOCATION; var scrollBehavior = options.scrollBehavior || DEFAULT_SCROLL_BEHAVIOR; var state = {}; var nextState = {}; var pendingTransition = null; var dispatchHandler = null; if (typeof location === 'string') location = new StaticLocation(location); if (location instanceof StaticLocation) { warning(!canUseDOM || process.env.NODE_ENV === 'test', 'You should not use a static location in a DOM environment because ' + 'the router will not be kept in sync with the current URL'); } else { invariant(canUseDOM || location.needsDOM === false, 'You cannot use %s without a DOM', location); } // Automatically fall back to full page refreshes in // browsers that don't support the HTML history API. if (location === HistoryLocation && !supportsHistory()) location = RefreshLocation; var Router = React.createClass({ displayName: 'Router', statics: { isRunning: false, cancelPendingTransition: function cancelPendingTransition() { if (pendingTransition) { pendingTransition.cancel(); pendingTransition = null; } }, clearAllRoutes: function clearAllRoutes() { Router.cancelPendingTransition(); Router.namedRoutes = {}; Router.routes = []; }, /** * Adds routes to this router from the given children object (see ReactChildren). */ addRoutes: function addRoutes(routes) { if (isReactChildren(routes)) routes = createRoutesFromReactChildren(routes); addRoutesToNamedRoutes(routes, Router.namedRoutes); Router.routes.push.apply(Router.routes, routes); }, /** * Replaces routes of this router from the given children object (see ReactChildren). */ replaceRoutes: function replaceRoutes(routes) { Router.clearAllRoutes(); Router.addRoutes(routes); Router.refresh(); }, /** * Performs a match of the given path against this router and returns an object * with the { routes, params, pathname, query } that match. Returns null if no * match can be made. */ match: function match(path) { return Match.findMatch(Router.routes, path); }, /** * Returns an absolute URL path created from the given route * name, URL parameters, and query. */ makePath: function makePath(to, params, query) { var path; if (PathUtils.isAbsolute(to)) { path = to; } else { var route = to instanceof Route ? to : Router.namedRoutes[to]; invariant(route instanceof Route, 'Cannot find a route named "%s"', to); path = route.path; } return PathUtils.withQuery(PathUtils.injectParams(path, params), query); }, /** * Returns a string that may safely be used as the href of a link * to the route with the given name, URL parameters, and query. */ makeHref: function makeHref(to, params, query) { var path = Router.makePath(to, params, query); return location === HashLocation ? '#' + path : path; }, /** * Transitions to the URL specified in the arguments by pushing * a new URL onto the history stack. */ transitionTo: function transitionTo(to, params, query) { var path = Router.makePath(to, params, query); if (pendingTransition) { // Replace so pending location does not stay in history. location.replace(path); } else { location.push(path); } }, /** * Transitions to the URL specified in the arguments by replacing * the current URL in the history stack. */ replaceWith: function replaceWith(to, params, query) { location.replace(Router.makePath(to, params, query)); }, /** * Transitions to the previous URL if one is available. Returns true if the * router was able to go back, false otherwise. * * Note: The router only tracks history entries in your application, not the * current browser session, so you can safely call this function without guarding * against sending the user back to some other site. However, when using * RefreshLocation (which is the fallback for HistoryLocation in browsers that * don't support HTML5 history) this method will *always* send the client back * because we cannot reliably track history length. */ goBack: function goBack() { if (History.length > 1 || location === RefreshLocation) { location.pop(); return true; } warning(false, 'goBack() was ignored because there is no router history'); return false; }, handleAbort: options.onAbort || function (abortReason) { if (location instanceof StaticLocation) throw new Error('Unhandled aborted transition! Reason: ' + abortReason); if (abortReason instanceof Cancellation) { return; } else if (abortReason instanceof Redirect) { location.replace(Router.makePath(abortReason.to, abortReason.params, abortReason.query)); } else { location.pop(); } }, handleError: options.onError || function (error) { // Throw so we don't silently swallow async errors. throw error; // This error probably originated in a transition hook. }, handleLocationChange: function handleLocationChange(change) { Router.dispatch(change.path, change.type); }, /** * Performs a transition to the given path and calls callback(error, abortReason) * when the transition is finished. If both arguments are null the router's state * was updated. Otherwise the transition did not complete. * * In a transition, a router first determines which routes are involved by beginning * with the current route, up the route tree to the first parent route that is shared * with the destination route, and back down the tree to the destination route. The * willTransitionFrom hook is invoked on all route handlers we're transitioning away * from, in reverse nesting order. Likewise, the willTransitionTo hook is invoked on * all route handlers we're transitioning to. * * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect the * transition. To resolve asynchronously, they may use the callback argument. If no * hooks wait, the transition is fully synchronous. */ dispatch: function dispatch(path, action) { Router.cancelPendingTransition(); var prevPath = state.path; var isRefreshing = action == null; if (prevPath === path && !isRefreshing) return; // Nothing to do! // Record the scroll position as early as possible to // get it before browsers try update it automatically. if (prevPath && action === LocationActions.PUSH) Router.recordScrollPosition(prevPath); var match = Router.match(path); warning(match != null, 'No route matches path "%s". Make sure you have somewhere in your routes', path, path); if (match == null) match = {}; var prevRoutes = state.routes || []; var prevParams = state.params || {}; var prevQuery = state.query || {}; var nextRoutes = match.routes || []; var nextParams = match.params || {}; var nextQuery = match.query || {}; var fromRoutes, toRoutes; if (prevRoutes.length) { fromRoutes = prevRoutes.filter(function (route) { return !hasMatch(nextRoutes, route, prevParams, nextParams, prevQuery, nextQuery); }); toRoutes = nextRoutes.filter(function (route) { return !hasMatch(prevRoutes, route, prevParams, nextParams, prevQuery, nextQuery); }); } else { fromRoutes = []; toRoutes = nextRoutes; } var transition = new Transition(path, Router.replaceWith.bind(Router, path)); pendingTransition = transition; var fromComponents = mountedComponents.slice(prevRoutes.length - fromRoutes.length); Transition.from(transition, fromRoutes, fromComponents, function (error) { if (error || transition.abortReason) return dispatchHandler.call(Router, error, transition); // No need to continue. Transition.to(transition, toRoutes, nextParams, nextQuery, function (error) { dispatchHandler.call(Router, error, transition, { path: path, action: action, pathname: match.pathname, routes: nextRoutes, params: nextParams, query: nextQuery }); }); }); }, /** * Starts this router and calls callback(router, state) when the route changes. * * If the router's location is static (i.e. a URL path in a server environment) * the callback is called only once. Otherwise, the location should be one of the * Router.*Location objects (e.g. Router.HashLocation or Router.HistoryLocation). */ run: function run(callback) { invariant(!Router.isRunning, 'Router is already running'); dispatchHandler = function (error, transition, newState) { if (error) Router.handleError(error); if (pendingTransition !== transition) return; pendingTransition = null; if (transition.abortReason) { Router.handleAbort(transition.abortReason); } else { callback.call(Router, Router, nextState = newState); } }; if (!(location instanceof StaticLocation)) { if (location.addChangeListener) location.addChangeListener(Router.handleLocationChange); Router.isRunning = true; } // Bootstrap using the current path. Router.refresh(); }, refresh: function refresh() { Router.dispatch(location.getCurrentPath(), null); }, stop: function stop() { Router.cancelPendingTransition(); if (location.removeChangeListener) location.removeChangeListener(Router.handleLocationChange); Router.isRunning = false; }, getLocation: function getLocation() { return location; }, getScrollBehavior: function getScrollBehavior() { return scrollBehavior; }, getRouteAtDepth: function getRouteAtDepth(routeDepth) { var routes = state.routes; return routes && routes[routeDepth]; }, setRouteComponentAtDepth: function setRouteComponentAtDepth(routeDepth, component) { mountedComponents[routeDepth] = component; }, /** * Returns the current URL path + query string. */ getCurrentPath: function getCurrentPath() { return state.path; }, /** * Returns the current URL path without the query string. */ getCurrentPathname: function getCurrentPathname() { return state.pathname; }, /** * Returns an object of the currently active URL parameters. */ getCurrentParams: function getCurrentParams() { return state.params; }, /** * Returns an object of the currently active query parameters. */ getCurrentQuery: function getCurrentQuery() { return state.query; }, /** * Returns an array of the currently active routes. */ getCurrentRoutes: function getCurrentRoutes() { return state.routes; }, /** * Returns true if the given route, params, and query are active. */ isActive: function isActive(to, params, query) { if (PathUtils.isAbsolute(to)) return to === state.path; return routeIsActive(state.routes, to) && paramsAreActive(state.params, params) && (query == null || queryIsActive(state.query, query)); } }, mixins: [ScrollHistory], propTypes: { children: PropTypes.falsy }, childContextTypes: { routeDepth: PropTypes.number.isRequired, router: PropTypes.router.isRequired }, getChildContext: function getChildContext() { return { routeDepth: 1, router: Router }; }, getInitialState: function getInitialState() { return state = nextState; }, componentWillReceiveProps: function componentWillReceiveProps() { this.setState(state = nextState); }, componentWillUnmount: function componentWillUnmount() { Router.stop(); }, render: function render() { var route = Router.getRouteAtDepth(0); return route ? React.createElement(route.handler, this.props) : null; } }); Router.clearAllRoutes(); if (options.routes) Router.addRoutes(options.routes); return Router; } module.exports = createRouter;