diff --git a/examples/animations/app.js b/examples/animations/app.js index 8d6e6c489a..cbb93c18fc 100644 --- a/examples/animations/app.js +++ b/examples/animations/app.js @@ -1,9 +1,11 @@ -import React, { cloneElement } from 'react/addons'; -import { history } from 'react-router/lib/HashHistory'; +import React from 'react/addons'; +import createHistory from 'history/lib/createHashHistory'; import { Router, Route, Link } from 'react-router'; var { CSSTransitionGroup } = React.addons; +var history = createHistory(); + var App = React.createClass({ render() { var key = this.props.location.pathname; @@ -15,7 +17,7 @@ var App = React.createClass({
  • Page 2
  • - {cloneElement(this.props.children ||
    , { key: key })} + {React.cloneElement(this.props.children ||
    , { key: key })}
    ); @@ -44,7 +46,6 @@ var Page2 = React.createClass({ } }); - React.render(( diff --git a/examples/auth-flow/app.js b/examples/auth-flow/app.js index 4635cc57eb..85873aa921 100644 --- a/examples/auth-flow/app.js +++ b/examples/auth-flow/app.js @@ -1,8 +1,9 @@ import React, { findDOMNode } from 'react'; +import createHistory from 'history/lib/createHashHistory'; import { Router, Route, Link, Navigation } from 'react-router'; -import HashHistory from 'react-router/lib/HashHistory'; import auth from './auth'; -var history = new HashHistory({ queryKey: true }); + +var history = createHistory(); var App = React.createClass({ getInitialState() { @@ -11,14 +12,14 @@ var App = React.createClass({ }; }, - setStateOnAuth(loggedIn) { + updateAuth(loggedIn) { this.setState({ - loggedIn: loggedIn + loggedIn: !!loggedIn }); }, componentWillMount() { - auth.onChange = this.setStateOnAuth; + auth.onChange = this.updateAuth; auth.login(); }, @@ -45,6 +46,7 @@ var App = React.createClass({ var Dashboard = React.createClass({ render() { var token = auth.getToken(); + return (

    Dashboard

    @@ -56,7 +58,6 @@ var Dashboard = React.createClass({ }); var Login = React.createClass({ - mixins: [ Navigation ], getInitialState() { @@ -115,9 +116,9 @@ var Logout = React.createClass({ } }); -function requireAuth(nextState, transition) { +function requireAuth(nextState, redirectTo) { if (!auth.loggedIn()) - transition.to('/login', null, { nextPathname: nextState.location.pathname }); + redirectTo('/login', null, { nextPathname: nextState.location.pathname }); } React.render(( diff --git a/examples/dynamic-segments/app.js b/examples/dynamic-segments/app.js index a46c96bff0..ba01d9fe7f 100644 --- a/examples/dynamic-segments/app.js +++ b/examples/dynamic-segments/app.js @@ -1,7 +1,9 @@ import React from 'react'; -import { history } from 'react-router/lib/HashHistory'; +import createHistory from 'history/lib/createHashHistory'; import { Router, Route, Link, Redirect } from 'react-router'; +var history = createHistory(); + var App = React.createClass({ render() { return ( @@ -50,8 +52,8 @@ React.render(( - - + + diff --git a/examples/transitions/app.js b/examples/transitions/app.js index 8fca7f7e51..c8645b71df 100644 --- a/examples/transitions/app.js +++ b/examples/transitions/app.js @@ -1,6 +1,8 @@ -import React, { findDOMNode } from 'react'; -import { Router, Route, Link, Navigation, TransitionHook } from 'react-router'; -import { history } from 'react-router/lib/HashHistory'; +import React from 'react'; +import createHistory from 'history/lib/createHashHistory'; +import { Router, Route, Link, Navigation } from 'react-router'; + +var history = createHistory(); var App = React.createClass({ render() { @@ -29,18 +31,43 @@ var Dashboard = React.createClass({ }); var Form = React.createClass({ - mixins: [ Navigation, TransitionHook ], + mixins: [ Navigation ], + + getInitialState() { + return { + textValue: 'ohai' + }; + }, - routerWillLeave(nextState, transition) { - if (findDOMNode(this.refs.userInput).value !== '') - if (!confirm('You have unsaved information, are you sure you want to leave this page?')) - transition.abort(); + transitionHook() { + if (this.state.textValue) + return 'You have unsaved information, are you sure you want to leave this page?'; + }, + + componentDidMount() { + history.registerTransitionHook(this.transitionHook); + }, + + componentWillUnmount() { + history.unregisterTransitionHook(this.transitionHook); + }, + + handleChange(event) { + var { value } = event.target; + + this.setState({ + textValue: value + }); }, handleSubmit(event) { event.preventDefault(); - findDOMNode(this.refs.userInput).value = ''; - this.transitionTo('/'); + + this.setState({ + textValue: '' + }, () => { + this.transitionTo('/'); + }); }, render() { @@ -48,7 +75,7 @@ var Form = React.createClass({

    Click the dashboard link with text in the input.

    - +
    diff --git a/modules/ActiveMixin.js b/modules/ActiveMixin.js new file mode 100644 index 0000000000..4623c113ac --- /dev/null +++ b/modules/ActiveMixin.js @@ -0,0 +1,69 @@ +import { matchPattern } from './PatternUtils'; + +/** + * Returns true if a route and params that match the given + * pathname are currently active. + */ +function pathnameIsActive(pathname, activePathname, activeRoutes, activeParams) { + if (pathname === activePathname || activePathname.indexOf(pathname + '/') === 0) + return true; + + var route, pattern, basename; + for (var i = 0, len = activeRoutes.length; i < len; ++i) { + route = activeRoutes[i]; + pattern = route.path || ''; + + if (pattern.indexOf('/') !== 0) + pattern = basename.replace(/\/*$/, '/') + pattern; // Relative paths build on the parent's path. + + var { remainingPathname, paramNames, paramValues } = matchPattern(pattern, pathname); + + if (remainingPathname === '') { + return paramNames.every(function (paramName, index) { + return String(paramValues[index]) === String(activeParams[paramName]); + }); + } + + basename = pattern; + } + + return false; +} + +/** + * Returns true if all key/value pairs in the given query are + * currently active. + */ +function queryIsActive(query, activeQuery) { + if (activeQuery == null) + return query == null; + + if (query == null) + return true; + + for (var p in query) + if (query.hasOwnProperty(p) && String(query[p]) !== String(activeQuery[p])) + return false; + + return true; +} + +var ActiveMixin = { + + /** + * Returns true if a to the given pathname/query combination is + * currently active. + */ + isActive(pathname, query) { + var { location, routes, params } = this.state; + + if (location == null) + return false; + + return pathnameIsActive(pathname, location.pathname, routes, params) && + queryIsActive(query, location.query); + } + +}; + +export default ActiveMixin; diff --git a/modules/BrowserHistory.js b/modules/BrowserHistory.js deleted file mode 100644 index bdc05e7731..0000000000 --- a/modules/BrowserHistory.js +++ /dev/null @@ -1,73 +0,0 @@ -import DOMHistory from './DOMHistory'; -import { addEventListener, removeEventListener, getWindowPath, supportsHistory } from './DOMUtils'; - -/** - * A history implementation for DOM environments that support the - * HTML5 history API (pushState, replaceState, and the popstate event). - * Provides the cleanest URLs and should always be used in browser - * environments if possible. - * - * Note: BrowserHistory automatically falls back to using full page - * refreshes if HTML5 history is not available, so URLs are always - * the same across browsers. - */ -class BrowserHistory extends DOMHistory { - - constructor(options) { - super(options); - this.handlePopState = this.handlePopState.bind(this); - this.isSupported = supportsHistory(); - } - - setup() { - if (this.location != null) - return; - - var path = getWindowPath(); - var key = null; - if (this.isSupported && window.history.state) - key = window.history.state.key; - - super.setup(path, { key }); - - addEventListener(window, 'popstate', this.handlePopState); - } - - teardown() { - removeEventListener(window, 'popstate', this.handlePopState); - super.teardown(); - } - - handlePopState(event) { - if (event.state === undefined) - return; // Ignore extraneous popstate events in WebKit. - - var path = getWindowPath(); - var key = event.state && event.state.key; - this.handlePop(path, { key }); - } - - // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-pushstate - push(path, key) { - if (this.isSupported) { - var state = { key }; - window.history.pushState(state, '', path); - return state; - } - - window.location = path; - } - - // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-replacestate - replace(path, key) { - if (this.isSupported) { - var state = { key }; - window.history.replaceState(state, '', path); - return state; - } - window.location.replace(path); - } -} - -export var history = new BrowserHistory; -export default BrowserHistory; diff --git a/modules/DOMHistory.js b/modules/DOMHistory.js deleted file mode 100644 index 17c8851998..0000000000 --- a/modules/DOMHistory.js +++ /dev/null @@ -1,72 +0,0 @@ -import History from './History'; -import { addEventListener, removeEventListener, getWindowScrollPosition } from './DOMUtils'; -import NavigationTypes from './NavigationTypes'; - -/** - * A history interface that assumes a DOM environment. - */ -class DOMHistory extends History { - constructor(options={}) { - super(options); - this.getScrollPosition = options.getScrollPosition || getWindowScrollPosition; - this.handleBeforeUnload = this.handleBeforeUnload.bind(this); - } - - setup(path, entry) { - super.setup(path, entry); - addEventListener(window, 'beforeunload', this.handleBeforeUnload); - } - - teardown() { - removeEventListener(window, 'beforeunload', this.handleBeforeUnload); - super.teardown(); - } - - go(n) { - if (n === 0) - return; - - window.history.go(n); - } - - saveState(key, state) { - window.sessionStorage.setItem(key, JSON.stringify(state)); - } - - readState(key) { - var json = window.sessionStorage.getItem(key); - - if (json) { - try { - return JSON.parse(json); - } catch (error) { - // Ignore invalid JSON in session storage. - } - } - - return null; - } - - beforeChange(location, done) { - super.beforeChange(location, () => { - if (location.navigationType === NavigationTypes.PUSH && this.canUpdateState()) { - var scrollPosition = this.getScrollPosition(); - this.updateState(scrollPosition); - } - done(); - }); - } - - handleBeforeUnload(event) { - var message = this.beforeChangeListener.call(this); - - if (message != null) { - // cross browser, see https://developer.mozilla.org/en-US/docs/Web/Events/beforeunload - (event || window.event).returnValue = message; - return message; - } - } - -} - -export default DOMHistory; diff --git a/modules/DOMUtils.js b/modules/DOMUtils.js index 3952fa2712..e07aa4096e 100644 --- a/modules/DOMUtils.js +++ b/modules/DOMUtils.js @@ -36,8 +36,8 @@ export function getWindowPath() { export function getWindowScrollPosition() { return { - scrollX: window.pageXOffset || document.documentElement.scrollLeft, - scrollY: window.pageYOffset || document.documentElement.scrollTop + x: window.pageXOffset || document.documentElement.scrollLeft, + y: window.pageYOffset || document.documentElement.scrollTop }; } diff --git a/modules/HashHistory.js b/modules/HashHistory.js deleted file mode 100644 index 70fdfd00ed..0000000000 --- a/modules/HashHistory.js +++ /dev/null @@ -1,125 +0,0 @@ -import warning from 'warning'; -import DOMHistory from './DOMHistory'; -import { addEventListener, removeEventListener, getHashPath, replaceHashPath } from './DOMUtils'; -import { isAbsolutePath } from './URLUtils'; - -var DefaultQueryKey = '_qk'; - -function ensureSlash() { - var path = getHashPath(); - - if (isAbsolutePath(path)) - return true; - - replaceHashPath('/' + path); - - return false; -} - -function addQueryStringValueToPath(path, key, value) { - return path + (path.indexOf('?') === -1 ? '?' : '&') + `${key}=${value}`; -} - -function getQueryStringValueFromPath(path, key) { - var match = path.match(new RegExp(`\\?.*?\\b${key}=(.+?)\\b`)); - return match && match[1]; -} - -/** - * A history implementation for DOM environments that uses window.location.hash - * to store the current path. This is essentially a hack for older browsers that - * do not support the HTML5 history API (IE <= 9). - * - * Support for persistence of state across page refreshes is provided using a - * combination of a URL query string parameter and DOM storage. However, this - * support is not enabled by default. In order to use it, create your own - * HashHistory. - * - * import HashHistory from 'react-router/lib/HashHistory'; - * var StatefulHashHistory = new HashHistory({ queryKey: '_key' }); - * React.render(, ...); - */ -class HashHistory extends DOMHistory { - - constructor(options={}) { - super(options); - this.handleHashChange = this.handleHashChange.bind(this); - this.queryKey = options.queryKey; - - if (typeof this.queryKey !== 'string') - this.queryKey = this.queryKey ? DefaultQueryKey : null; - } - - setup() { - if (this.location != null) - return; - - ensureSlash(); - - var path = getHashPath(); - var key = getQueryStringValueFromPath(path, this.queryKey); - - super.setup(path, { key }); - - addEventListener(window, 'hashchange', this.handleHashChange); - } - - teardown() { - removeEventListener(window, 'hashchange', this.handleHashChange); - super.teardown(); - } - - handleHashChange() { - if (!ensureSlash()) - return; - - if (this._ignoreNextHashChange) { - this._ignoreNextHashChange = false; - } else { - var path = getHashPath(); - var key = getQueryStringValueFromPath(path, this.queryKey); - this.handlePop(path, { key }); - } - } - - push(path, key) { - var actualPath = path; - if (this.queryKey) - actualPath = addQueryStringValueToPath(path, this.queryKey, key); - - - if (actualPath === getHashPath()) { - warning( - false, - 'HashHistory can not push the current path' - ); - } else { - this._ignoreNextHashChange = true; - window.location.hash = actualPath; - } - - return { key: this.queryKey && key }; - } - - - replace(path, key) { - var actualPath = path; - if (this.queryKey) - actualPath = addQueryStringValueToPath(path, this.queryKey, key); - - - if (actualPath !== getHashPath()) - this._ignoreNextHashChange = true; - - replaceHashPath(actualPath); - - return { key: this.queryKey && key }; - } - - makeHref(path) { - return '#' + path; - } -} - -export var history = new HashHistory; -export default HashHistory; diff --git a/modules/History.js b/modules/History.js deleted file mode 100644 index 6c3d09de55..0000000000 --- a/modules/History.js +++ /dev/null @@ -1,234 +0,0 @@ -import invariant from 'invariant'; -import warning from 'warning'; -import NavigationTypes from './NavigationTypes'; -import { getPathname, getQueryString, parseQueryString } from './URLUtils'; -import Location from './Location'; - -var RequiredHistorySubclassMethods = [ 'push', 'replace', 'go' ]; - -/** - * A history interface that normalizes the differences across - * various environments and implementations. Requires concrete - * subclasses to implement the following methods: - * - * - pushState(state, path) - * - replaceState(state, path) - * - go(n) - */ -class History { - - constructor(options={}) { - RequiredHistorySubclassMethods.forEach(function (method) { - invariant( - typeof this[method] === 'function', - '%s needs a "%s" method', - this.constructor.name, method - ); - }, this); - - this.parseQueryString = options.parseQueryString || parseQueryString; - - this.changeListeners = []; - this.beforeChangeListener = null; - - this.path = null; - this.location = null; - this._pendingLocation = null; - } - - _notifyChange() { - for (var i = 0, len = this.changeListeners.length; i < len; ++i) - this.changeListeners[i].call(this); - } - - addChangeListener(listener) { - this.changeListeners.push(listener); - } - - removeChangeListener(listener) { - this.changeListeners = this.changeListeners.filter(function (li) { - return li !== listener; - }); - } - - onBeforeChange(listener) { - warning( - this.beforeChangeListener != null, - 'beforeChange listener of History should not be overwritten' - ); - - this.beforeChangeListener = listener; - } - - setup(path, entry = {}) { - if (this.location) - return; - - if (!entry.key) - entry = this.replace(path, this.createRandomKey()); - - var state = null; - if (typeof this.readState === 'function') - state = this.readState(entry.key); - - var location = this._createLocation(path, state, entry, NavigationTypes.POP); - this._update(path, location, false); - } - - teardown() { - this.changeListeners = []; - this.beforeChangeListener = null; - - this.path = null; - this.location = null; - this._pendingLocation = null; - } - - handlePop(path, entry={}) { - var state = null; - if (entry.key && typeof this.readState === 'function') - state = this.readState(entry.key); - - var pendingLocation = this._createLocation(path, state, entry, NavigationTypes.POP); - - this.beforeChange(pendingLocation, () => { - this._update(path, pendingLocation); - }); - } - - createRandomKey() { - return Math.random().toString(36).substr(2); - } - - _saveNewState(state) { - var key = this.createRandomKey(); - - if (state != null) { - invariant( - typeof this.saveState === 'function', - '%s needs a saveState method in order to store state', - this.constructor.name - ); - - this.saveState(key, state); - } - - return key; - } - - canUpdateState() { - return typeof this.readState === 'function' - && typeof this.saveState === 'function' - && this.location - && this.location.state - && this.location.state.key; - } - - updateState(extraState) { - invariant( - this.canUpdateState(), - '%s is unable to update state right now', - this.constructor.name - ); - - var key = this.location.state.key; - var state = this.readState(key); - this.saveState(key, { ...state, ...extraState }); - } - - beforeChange(location, done) { - if (!this.beforeChangeListener) { - done(); - } else { - this._pendingLocation = location; - - this.beforeChangeListener.call(this, location, () => { - if (this._pendingLocation === location) { - this._pendingLocation = null; - done(); - return true; - } - return false; - }); - } - } - - isPending(location) { - return this._pendingLocation === location; - } - - pushState(state, path) { - var pendingLocation = this._createLocation(path, state, null, NavigationTypes.PUSH); - this.beforeChange(pendingLocation, () => { - this._doPushState(state, path) - }); - } - - _doPushState(state, path) { - var key = this._saveNewState(state); - var entry = null; - - if (this.path === path) { - entry = this.replace(path, key) || {}; - } else { - entry = this.push(path, key) || {}; - } - - warning( - entry.key || state == null, - '%s does not support storing state', - this.constructor.name - ); - - var location = this._createLocation(path, state, entry, NavigationTypes.PUSH); - this._update(path, location); - } - - replaceState(state, path) { - var pendingLocation = this._createLocation(path, state, null, NavigationTypes.REPLACE); - this.beforeChange(pendingLocation, () => { - this._doReplaceState(state, path); - }); - } - - _doReplaceState(state, path) { - var key = this._saveNewState(state); - var entry = this.replace(path, key) || {}; - - warning( - entry.key || state == null, - '%s does not support storing state', - this.constructor.name - ); - - var location = this._createLocation(path, state, entry, NavigationTypes.REPLACE); - this._update(path, location); - } - - back() { - this.go(-1); - } - - forward() { - this.go(1); - } - - _update(path, location, notify=true) { - this.path = path; - this.location = location; - this._pendingLocation = null; - - if (notify) - this._notifyChange(); - } - - _createLocation(path, state, entry, navigationType) { - var pathname = getPathname(path); - var queryString = getQueryString(path); - var query = queryString ? this.parseQueryString(queryString) : null; - return new Location(pathname, query, {...state, ...entry}, navigationType); - } - -} - -export default History; diff --git a/modules/Link.js b/modules/Link.js index 92784720a6..48fca93a38 100644 --- a/modules/Link.js +++ b/modules/Link.js @@ -1,6 +1,6 @@ -import React from 'react'; +import { createClass, createElement, PropTypes } from 'react'; -var { object, string, func } = React.PropTypes; +var { object, string, func } = PropTypes; function isLeftClickEvent(event) { return event.button === 0; @@ -26,9 +26,9 @@ function isModifiedEvent(event) { * Links may pass along query string parameters * using the `query` prop. * - * + * */ -export var Link = React.createClass({ +var Link = createClass({ contextTypes: { router: object @@ -72,23 +72,28 @@ export var Link = React.createClass({ render() { var { router } = this.context; - var { to, query } = this.props; var props = Object.assign({}, this.props, { - href: router.makeHref(to, query), onClick: this.handleClick }); - // ignore if rendered outside of the context of a router, simplifies unit testing - if (router && router.isActive(to, query)) { - if (props.activeClassName) - props.className += props.className !== '' ? ` ${props.activeClassName}` : props.activeClassName; + // Ignore if rendered outside of the context of a + // router, simplifies unit testing. + if (router) { + var { to, query } = this.props; - if (props.activeStyle) - props.style = Object.assign({}, props.style, props.activeStyle); + props.href = router.createHref(to, query); + + if (router.isActive(to, query)) { + if (props.activeClassName) + props.className += props.className !== '' ? ` ${props.activeClassName}` : props.activeClassName; + + if (props.activeStyle) + props.style = Object.assign({}, props.style, props.activeStyle); + } } - return React.createElement('a', props); + return createElement('a', props); } }); diff --git a/modules/Location.js b/modules/Location.js deleted file mode 100644 index 0210b60c96..0000000000 --- a/modules/Location.js +++ /dev/null @@ -1,24 +0,0 @@ -import NavigationTypes from './NavigationTypes'; - -/** - * A Location answers two important questions: - * - * 1. Where am I? - * 2. How did I get here? - */ -class Location { - - static isLocation(object) { - return object instanceof Location; - } - - constructor(pathname='/', query=null, state=null, navigationType=NavigationTypes.POP) { - this.pathname = pathname; - this.query = query; - this.state = state; - this.navigationType = navigationType; - } - -} - -export default Location; diff --git a/modules/MemoryHistory.js b/modules/MemoryHistory.js deleted file mode 100644 index e6d87373fb..0000000000 --- a/modules/MemoryHistory.js +++ /dev/null @@ -1,120 +0,0 @@ -import invariant from 'invariant'; -import History from './History'; - -/** - * A concrete History class that doesn't require a DOM. Ideal - * for testing because it allows you to specify route history - * entries in the constructor. - */ -class MemoryHistory extends History { - - constructor(entries, current) { - super(); - - if (entries == null) { - entries = [ '/' ]; - } else if (typeof entries === 'string') { - entries = [ entries ]; - } else if (!Array.isArray(entries)) { - throw new Error('MemoryHistory needs an array of entries'); - } - - entries = entries.map(this._createEntry.bind(this)); - - if (current == null) { - current = entries.length - 1; - } else { - invariant( - current >= 0 && current < entries.length, - '%s current index must be >= 0 and < %s, was %s', - this.constructor.name, entries.length, current - ); - } - - this.current = current; - this.entries = entries; - this.storage = entries - .filter(entry => entry.state) - .reduce((all, entry) => { - all[entry.key] = entry.state; - return all; - }, {}); - } - - setup() { - if (this.location) - return; - - var entry = this.entries[this.current]; - var path = entry.path; - var key = entry.key; - - super.setup(path, { key, current: this.current }); - } - - _createEntry(object) { - var key = this.createRandomKey(); - if (typeof object === 'string') - return { path: object, key }; - - if (typeof object === 'object' && object) - return {...object, key}; - - throw new Error('Unable to create history entry from ' + object); - } - - // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-pushstate - push(path, key) { - this.current += 1; - this.entries = this.entries.slice(0, this.current).concat([{ key, path }]); - - return { key, current: this.current }; - } - - // http://www.w3.org/TR/2011/WD-html5-20110113/history.html#dom-history-replacestate - replace(path, key) { - this.entries[this.current] = { key, path }; - - return { key, current: this.current }; - } - - readState(key) { - return this.storage[key]; - } - - saveState(key, state){ - this.storage[key] = state; - } - - go(n) { - if (n === 0) - return; - - invariant( - this.canGo(n), - '%s cannot go(%s) because there is not enough history', - this.constructor.name, n - ); - - this.current += n; - var entry = this.entries[this.current]; - - this.handlePop(entry.path, { key: entry.key, current: this.current }); - } - - canGo(n) { - var index = this.current + n; - return index >= 0 && index < this.entries.length; - } - - canGoBack() { - return this.canGo(-1); - } - - canGoForward() { - return this.canGo(1); - } - -} - -export default MemoryHistory; diff --git a/modules/NavigationMixin.js b/modules/NavigationMixin.js new file mode 100644 index 0000000000..71d2faea30 --- /dev/null +++ b/modules/NavigationMixin.js @@ -0,0 +1,79 @@ +import invariant from 'invariant'; + +var NavigationMixin = { + + /** + * Returns a string that may safely be used to link to the given + * pathname and query. + */ + createHref(pathname, query) { + var path = this.createPath(pathname, query); + var { history } = this.props; + + if (history && history.createHref) + return history.createHref(path); + + return path; + }, + + /** + * Pushes a new Location onto the history stack. + */ + transitionTo(pathname, query, state=null) { + var { history } = this.props; + + invariant( + history, + 'Router#transitionTo needs history' + ); + + history.pushState(state, this.createPath(pathname, query)); + }, + + /** + * Replaces the current Location on the history stack. + */ + replaceWith(pathname, query, state=null) { + var { history } = this.props; + + invariant( + history, + 'Router#replaceWith needs history' + ); + + history.replaceState(state, this.createPath(pathname, query)); + }, + + /** + * Navigates forward/backward n entries in the history stack. + */ + go(n) { + var { history } = this.props; + + invariant( + history, + 'Router#go needs history' + ); + + history.go(n); + }, + + /** + * Navigates back one entry in the history stack. This is identical to + * the user clicking the browser's back button. + */ + goBack() { + this.go(-1); + }, + + /** + * Navigates forward one entry in the history stack. This is identical to + * the user clicking the browser's forward button. + */ + goForward() { + this.go(1); + } + +}; + +export default NavigationMixin; diff --git a/modules/PropTypes.js b/modules/PropTypes.js index 0ae84959e2..87ed815b11 100644 --- a/modules/PropTypes.js +++ b/modules/PropTypes.js @@ -1,27 +1,28 @@ -import React from 'react'; -import Location from './Location'; -import History from './History'; +import { PropTypes } from 'react'; -var { func, object, arrayOf, instanceOf, oneOfType, element } = React.PropTypes; +var { func, object, arrayOf, oneOfType, element, shape, string } = PropTypes; -function falsy(props, propName, componentName) { +export function falsy(props, propName, componentName) { if (props[propName]) return new Error(`<${componentName}> should not have a "${propName}" prop`); } -var component = func; -var components = oneOfType([ component, object ]); -var history = instanceOf(History); -var location = instanceOf(Location); -var route = oneOfType([ object, element ]); -var routes = oneOfType([ route, arrayOf(route) ]); +export var history = shape({ + listen: func.isRequired, + pushState: func.isRequired, + replaceState: func.isRequired, + go: func.isRequired +}); -module.exports = { - falsy, - component, - components, - history, - location, - route, - routes -}; +export var location = shape({ + pathname: string.isRequired, + search: string.isRequired, + state: object, + action: string.isRequired, + key: string +}); + +export var component = func; +export var components = oneOfType([ component, object ]); +export var route = oneOfType([ object, element ]); +export var routes = oneOfType([ route, arrayOf(route) ]); diff --git a/modules/Redirect.js b/modules/Redirect.js index 3eacc970ec..9edfe3bfb9 100644 --- a/modules/Redirect.js +++ b/modules/Redirect.js @@ -1,12 +1,19 @@ -import React from 'react'; import invariant from 'invariant'; +import { createClass, PropTypes } from 'react'; import { createRouteFromReactElement } from './RouteUtils'; -import { formatPattern } from './URLUtils'; +import { formatPattern } from './PatternUtils'; import { falsy } from './PropTypes'; -var { string, object } = React.PropTypes; +var { string, object } = PropTypes; -export var Redirect = React.createClass({ +/** + * A is used to declare another URL path a client should be sent + * to when they request a given URL. + * + * Redirects are placed alongside routes in the route configuration and are + * traversed in the same manner. + */ +var Redirect = createClass({ statics: { @@ -16,11 +23,13 @@ export var Redirect = React.createClass({ if (route.from) route.path = route.from; - route.onEnter = function (nextState, transition) { + route.onEnter = function (nextState, redirectTo) { var { location, params } = nextState; + + // TODO: Handle relative pathnames. var pathname = route.to ? formatPattern(route.to, params) : location.pathname; - transition.to( + redirectTo( pathname, route.query || location.query, route.state || location.state diff --git a/modules/Route.js b/modules/Route.js index 7f8ca13547..fb95c2bcb4 100644 --- a/modules/Route.js +++ b/modules/Route.js @@ -1,10 +1,10 @@ -import React from 'react'; +import warning from 'warning'; import invariant from 'invariant'; +import { createClass, PropTypes } from 'react'; import { createRouteFromReactElement } from './RouteUtils'; import { component, components } from './PropTypes'; -import warning from 'warning'; -var { string, bool, func } = React.PropTypes; +var { string, bool, func } = PropTypes; /** * A is used to declare which components are rendered to the page when @@ -16,7 +16,7 @@ var { string, bool, func } = React.PropTypes; * "active" and their components are rendered into the DOM, nested in the same * order as they are in the tree. */ -export var Route = React.createClass({ +var Route = createClass({ statics: { diff --git a/modules/Router.js b/modules/Router.js index 7b73e9742c..e00166df11 100644 --- a/modules/Router.js +++ b/modules/Router.js @@ -1,105 +1,93 @@ -import React, { createElement, isValidElement } from 'react'; import warning from 'warning'; import invariant from 'invariant'; -import { loopAsync } from './AsyncUtils'; +import { createClass, createElement, isValidElement, PropTypes } from 'react'; +import { component, components, history, location, routes } from './PropTypes'; import { createRoutes } from './RouteUtils'; -import { getState, getTransitionHooks, getComponents, getRouteParams, createTransitionHook } from './RoutingUtils'; -import { routes, component, components, history, location } from './PropTypes'; -import RouterContextMixin from './RouterContextMixin'; +import matchRoutes from './matchRoutes'; +import runTransitionHooks from './runTransitionHooks'; +import getComponents from './getComponents'; +import getRouteParams from './getRouteParams'; + +import NavigationMixin from './NavigationMixin'; import ScrollManagementMixin from './ScrollManagementMixin'; -import { isLocation } from './Location'; -import Transition from './Transition'; +import ActiveMixin from './ActiveMixin'; -var { arrayOf, func, object } = React.PropTypes; +var { arrayOf, func, object } = PropTypes; -function runTransition(prevState, routes, location, hooks, callback) { - var transition = new Transition; +import qs from 'qs'; - getState(routes, location, function (error, nextState) { - if (error || nextState == null || transition.isCancelled) { - callback(error, null, transition); - } else { - nextState.location = location; - - var transitionHooks = getTransitionHooks(prevState, nextState); - if (Array.isArray(hooks)) - transitionHooks.unshift.apply(transitionHooks, hooks); - - loopAsync(transitionHooks.length, (index, next, done) => { - transitionHooks[index](nextState, transition, (error) => { - if (error || transition.isCancelled) { - done(error); // No need to continue. - } else { - next(); - } - }); - }, function (error) { - if (error || transition.isCancelled) { - callback(error, null, transition); +function stringifyQuery(query) { + return qs.stringify(query, { arrayFormat: 'brackets' }); +} + +function parseQueryString(queryString) { + return qs.parse(queryString); +} + +var Router = createClass({ + + mixins: [ NavigationMixin, ScrollManagementMixin, ActiveMixin ], + + statics: { + + run(routes, location, callback, prevState=null) { + matchRoutes(routes, location, function (error, nextState) { + if (error || nextState == null) { + callback(error, null); } else { - getComponents(nextState.branch, function (error, components) { - if (error || transition.isCancelled) { - callback(error, null, transition); + nextState.location = location; + runTransitionHooks(prevState, nextState, function (error, redirectInfo) { + if (error || redirectInfo) { + callback(error, null, redirectInfo); } else { - nextState.components = components; - callback(null, nextState, transition); + getComponents(nextState, function (error, components) { + if (error) { + callback(error); + } else { + nextState.components = components; + callback(null, nextState); + } + }); } }); } }); } - }); -} - -var Router = React.createClass({ - - mixins: [ RouterContextMixin, ScrollManagementMixin ], - - statics: { - - /** - * Runs a transition to the given location using the given routes and - * transition hooks (optional) and calls callback(error, state, transition) - * when finished. This is primarily useful for server-side rendering. - */ - run(routes, location, transitionHooks, callback) { - if (typeof transitionHooks === 'function') { - callback = transitionHooks; - transitionHooks = null; - } - invariant( - typeof callback === 'function', - 'Router.run needs a callback' - ); + }, - runTransition(null, routes, location, transitionHooks, callback); - } + childContextTypes: { + router: object + }, + getChildContext() { + return { + router: this + }; }, propTypes: { - createElement: func.isRequired, - onAbort: func, + createElement: func, + parseQueryString: func, + stringifyQuery: func, onError: func, onUpdate: func, - - // Client-side - history, routes, // Routes may also be given as children (JSX) children: routes, + // Client-side + history, + // Server-side - location, - branch: routes, - params: object, - components: arrayOf(components) + location }, getDefaultProps() { return { - createElement + createElement, + parseQueryString, + stringifyQuery }; }, @@ -107,78 +95,50 @@ var Router = React.createClass({ return { isTransitioning: false, location: null, - branch: null, + routes: null, params: null, components: null }; }, - _updateState(location) { - invariant( - isLocation(location), - 'A needs a valid Location' - ); - - var hooks = this.transitionHooks; - if (hooks) - hooks = hooks.map(hook => createTransitionHook(hook, this)); + updateLocation(location) { + if (!location.query) + location.query = this.props.parseQueryString(location.search.substring(1)); - this.setState({ isTransitioning: true }); + this.setState({ + isTransitioning: true + }); - runTransition(this.state, this.routes, location, hooks, (error, state, transition) => { + Router.run(this.routes, location, (error, state, redirectInfo) => { if (error) { this.handleError(error); - } else if (transition.isCancelled) { - if (transition.redirectInfo) { - var { pathname, query, state } = transition.redirectInfo; - this.replaceWith(pathname, query, state); - } else { - invariant( - this.state.location, - 'You may not abort the initial transition' - ); - - this.handleAbort(transition.abortReason); - } + } else if (redirectInfo) { + var { pathname, query, state } = redirectInfo; + this.replaceWith(pathname, query, state); } else if (state == null) { - warning(false, 'Location "%s" did not match any routes', location.pathname); + warning( + false, + 'Location "%s" did not match any routes', + location.pathname + location.search + ); } else { this.setState(state, this.props.onUpdate); } - this.setState({ isTransitioning: false }); - }); - }, - - /** - * Adds a transition hook that runs before all route hooks in a - * transition. The signature is the same as route transition hooks. - */ - addTransitionHook(hook) { - if (!this.transitionHooks) - this.transitionHooks = []; - - this.transitionHooks.push(hook); - }, - - /** - * Removes the given transition hook. - */ - removeTransitionHook(hook) { - if (this.transitionHooks) - this.transitionHooks = this.transitionHooks.filter(h => h !== hook); + this.setState({ + isTransitioning: false + }); + }, this.state); }, - handleAbort(reason) { - if (this.props.onAbort) { - this.props.onAbort.call(this, reason); - } else { - // The best we can do here is goBack so the location state reverts - // to what it was. However, we also set a flag so that we know not - // to run through _updateState again since state did not change. - this._ignoreNextHistoryChange = true; - this.goBack(); + updateHistory(history) { + if (this._unlisten) { + this._unlisten(); + this._unlisten = null; } + + if (history) + this._unlisten = history.listen(this.updateLocation); }, handleError(error) { @@ -190,79 +150,49 @@ var Router = React.createClass({ } }, - handleHistoryChange() { - if (this._ignoreNextHistoryChange) { - this._ignoreNextHistoryChange = false; - } else { - this._updateState(this.props.history.location); - } - }, - componentWillMount() { - var { history, routes, children, location, branch, params, components } = this.props; - - if (history) { - invariant( - routes || children, - 'Client-side s need routes. Try using or ' + - 'passing your routes as nested children' - ); + var { routes, children, history, location } = this.props; - this.routes = createRoutes(routes || children); - - if (typeof history.setup === 'function') - history.setup(); - - // We need to listen first in case we redirect immediately. - if (history.addChangeListener) - history.addChangeListener(this.handleHistoryChange); + invariant( + routes || children, + 's need routes. Try using or ' + + 'passing your routes as nested children' + ); - this._updateState(history.location); - } else { - invariant( - location && branch && params && components, - 'Server-side s need location, branch, params, and components ' + - 'props. Try using Router.run to get all the props you need' - ); + this.routes = createRoutes(routes || children); - this.setState({ location, branch, params, components }); + if (history) { + this.updateHistory(history); + } else if (location) { + this.updateLocation(location); } }, componentWillReceiveProps(nextProps) { - invariant( - this.props.history === nextProps.history, - ' may not be changed' - ); - - if (nextProps.history) { - var currentRoutes = this.props.routes || this.props.children; - var nextRoutes = nextProps.routes || nextProps.children; - - if (currentRoutes !== nextRoutes) { - this.routes = createRoutes(nextRoutes); - - // Call this here because _updateState - // uses this.routes to determine state. - if (nextProps.history.location) - this._updateState(nextProps.history.location); - } - } + // TODO }, componentWillUnmount() { - var { history } = this.props; + if (this._unlisten) + this._unlisten(); + }, + + createPath(pathname, query) { + var { stringifyQuery } = this.props; + + var queryString; + if (query == null || (queryString = stringifyQuery(query)) === '') + return pathname; - if (history && history.removeChangeListener) - history.removeChangeListener(this.handleHistoryChange); + return pathname + (pathname.indexOf('?') === -1 ? '?' : '&') + queryString; }, - _createElement(component, props) { + createElement(component, props) { return typeof component === 'function' ? this.props.createElement(component, props) : null; }, render() { - var { branch, params, components } = this.state; + var { routes, params, components } = this.state; var element = null; if (components) { @@ -270,7 +200,7 @@ var Router = React.createClass({ if (components == null) return element; // Don't create new children; use the grandchildren. - var route = branch[index]; + var route = routes[index]; var routeParams = getRouteParams(route, params); var props = Object.assign({}, this.state, { route, routeParams }); @@ -286,12 +216,12 @@ var Router = React.createClass({ for (var key in components) if (components.hasOwnProperty(key)) - elements[key] = this._createElement(components[key], props); + elements[key] = this.createElement(components[key], props); return elements; } - return this._createElement(components, props); + return this.createElement(components, props); }, element); } diff --git a/modules/RouterContextMixin.js b/modules/RouterContextMixin.js deleted file mode 100644 index e2e35d7e64..0000000000 --- a/modules/RouterContextMixin.js +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react'; -import invariant from 'invariant'; -import { stripLeadingSlashes, stringifyQuery } from './URLUtils'; - -var { func, object } = React.PropTypes; - -function pathnameIsActive(pathname, activePathname) { - if (stripLeadingSlashes(activePathname).indexOf(stripLeadingSlashes(pathname)) === 0) - return true; // This quick comparison satisfies most use cases. - - // TODO: Implement a more stringent comparison that checks - // to see if the pathname matches any routes (and params) - // in the currently active branch. - - return false; -} - -function queryIsActive(query, activeQuery) { - if (activeQuery == null) - return query == null; - - if (query == null) - return true; - - for (var p in query) - if (query.hasOwnProperty(p) && String(query[p]) !== String(activeQuery[p])) - return false; - - return true; -} - -var RouterContextMixin = { - - propTypes: { - stringifyQuery: func.isRequired - }, - - getDefaultProps() { - return { - stringifyQuery - }; - }, - - childContextTypes: { - router: object.isRequired - }, - - getChildContext() { - return { - router: this - }; - }, - - /** - * Returns a full URL path from the given pathname and query. - */ - makePath(pathname, query) { - if (query) { - if (typeof query !== 'string') - query = this.props.stringifyQuery(query); - - if (query !== '') - return pathname + '?' + query; - } - - return pathname; - }, - - /** - * Returns a string that may safely be used to link to the given - * pathname and query. - */ - makeHref(pathname, query) { - var path = this.makePath(pathname, query); - var { history } = this.props; - - if (history && history.makeHref) - return history.makeHref(path); - - return path; - }, - - /** - * Pushes a new Location onto the history stack. - */ - transitionTo(pathname, query, state=null) { - var { history } = this.props; - - invariant( - history, - 'Router#transitionTo is client-side only (needs history)' - ); - - history.pushState(state, this.makePath(pathname, query)); - }, - - /** - * Replaces the current Location on the history stack. - */ - replaceWith(pathname, query, state=null) { - var { history } = this.props; - - invariant( - history, - 'Router#replaceWith is client-side only (needs history)' - ); - - history.replaceState(state, this.makePath(pathname, query)); - }, - - /** - * Navigates forward/backward n entries in the history stack. - */ - go(n) { - var { history } = this.props; - - invariant( - history, - 'Router#go is client-side only (needs history)' - ); - - history.go(n); - }, - - /** - * Navigates back one entry in the history stack. This is identical to - * the user clicking the browser's back button. - */ - goBack() { - this.go(-1); - }, - - /** - * Navigates forward one entry in the history stack. This is identical to - * the user clicking the browser's forward button. - */ - goForward() { - this.go(1); - }, - - /** - * Returns true if a to the given pathname/query combination is - * currently active. - */ - isActive(pathname, query) { - var { location } = this.state; - - if (location == null) - return false; - - return pathnameIsActive(pathname, location.pathname) && - queryIsActive(query, location.query); - } - -}; - -export default RouterContextMixin; diff --git a/modules/RoutingUtils.js b/modules/RoutingUtils.js deleted file mode 100644 index c9ab9ea8ec..0000000000 --- a/modules/RoutingUtils.js +++ /dev/null @@ -1,255 +0,0 @@ -import invariant from 'invariant'; -import { createRoutes } from './RouteUtils'; -import { getParamNames, matchPattern, stripLeadingSlashes } from './URLUtils'; -import { loopAsync, mapAsync } from './AsyncUtils'; - -function getChildRoutes(route, locationState, callback) { - if (route.childRoutes) { - callback(null, route.childRoutes); - } else if (route.getChildRoutes) { - route.getChildRoutes(locationState, callback); - } else { - callback(); - } -} - -function getIndexRoute(route, locationState, callback) { - if (route.indexRoute) { - callback(null, route.indexRoute); - } else if (route.getIndexRoute) { - route.getIndexRoute(callback, locationState); - } else { - callback(); - } -} - -function assignParams(params, paramNames, paramValues) { - return paramNames.reduceRight(function (params, paramName, index) { - var paramValue = paramValues[index]; - - if (Array.isArray(params[paramName])) { - params[paramName].unshift(paramValue); - } else if (paramName in params) { - params[paramName] = [ paramValue, params[paramName] ]; - } else { - params[paramName] = paramValue; - } - - return params; - }, params); -} - -function createParams(paramNames, paramValues) { - return assignParams({}, paramNames, paramValues); -} - -function matchRouteDeep(route, pathname, locationState, callback) { - var { remainingPathname, paramNames, paramValues } = matchPattern(route.path, pathname); - var isExactMatch = remainingPathname === ''; - - if (isExactMatch && route.path) { - var params = createParams(paramNames, paramValues); - var branch = [ route ]; - - getIndexRoute(route, locationState, function (error, indexRoute) { - if (error) { - callback(error); - } else { - if (indexRoute) - branch.push(indexRoute); - - callback(null, { params, branch }); - } - }); - } else if (remainingPathname != null) { - // This route matched at least some of the path. - getChildRoutes(route, locationState, function (error, childRoutes) { - if (error) { - callback(error); - } else if (childRoutes) { - // Check the child routes to see if any of them match. - matchRoutes(childRoutes, remainingPathname, locationState, function (error, match) { - if (error) { - callback(error); - } else if (match) { - // A child route matched! Augment the match and pass it up the stack. - assignParams(match.params, paramNames, paramValues); - match.branch.unshift(route); - callback(null, match); - } else { - callback(); - } - }); - } else { - callback(); - } - }); - } else { - callback(); - } -} - -function matchRoutes(routes, pathname, locationState, callback) { - routes = createRoutes(routes); - - loopAsync(routes.length, function (index, next, done) { - matchRouteDeep(routes[index], pathname, locationState, function (error, match) { - if (error || match) { - done(error, match); - } else { - next(); - } - }); - }, callback); -} - -/** - * Asynchronously matches the given location to a set of routes and calls - * callback(error, state) when finished. The state object may have the - * following properties: - * - * - branch An array of routes that matched, in hierarchical order - * - params An object of URL parameters - * - * Note: This operation may return synchronously if no routes have an - * asynchronous getChildRoutes method. - */ -export function getState(routes, location, callback) { - matchRoutes(routes, stripLeadingSlashes(location.pathname), location.state, callback); -} - -function routeParamsChanged(route, prevState, nextState) { - if (!route.path) - return false; - - var paramNames = getParamNames(route.path); - - return paramNames.some(function (paramName) { - return prevState.params[paramName] !== nextState.params[paramName]; - }); -} - -/** - * Runs a diff on the two router states and returns an array of two - * arrays: 1) the routes that we are leaving, starting with the leaf - * route and 2) the routes that we are entering, ending with the leaf - * route. - */ -function computeDiff(prevState, nextState) { - var fromRoutes = prevState && prevState.branch; - var toRoutes = nextState.branch; - - var leavingRoutes, enteringRoutes; - if (fromRoutes) { - leavingRoutes = fromRoutes.filter(function (route) { - return toRoutes.indexOf(route) === -1 || routeParamsChanged(route, prevState, nextState); - }); - - // onLeave hooks start at the leaf route. - leavingRoutes.reverse(); - - enteringRoutes = toRoutes.filter(function (route) { - return fromRoutes.indexOf(route) === -1 || leavingRoutes.indexOf(route) !== -1; - }); - } else { - leavingRoutes = []; - enteringRoutes = toRoutes; - } - - return [ - leavingRoutes, - enteringRoutes - ]; -} - -export function createTransitionHook(fn, context) { - return function (nextState, transition, callback) { - if (fn.length > 2) { - fn.call(context, nextState, transition, callback); - } else { - // Assume fn executes synchronously and - // automatically call the callback for them. - fn.call(context, nextState, transition); - callback(); - } - }; -} - -function getTransitionHooksFromRoutes(routes, hookName) { - return routes.reduce(function (hooks, route) { - if (route[hookName]) - hooks.push(createTransitionHook(route[hookName], route)); - - return hooks; - }, []); -} - -/** - * Compiles and returns an array of transition hook functions that - * should be called before we transition to a new state. Transition - * hook signatures are: - * - * - route.onLeave(nextState, transition[, callback ]) - * - route.onEnter(nextState, transition[, callback ]) - * - * Transition hooks run in order from the leaf route in the branch - * we're leaving, up the tree to the common parent route, and back - * down the branch we're entering to the leaf route. - * - * If a transition hook needs to execute asynchronously it may have - * a 3rd argument that it should call when it is finished. Otherwise - * the transition executes synchronously. - */ -export function getTransitionHooks(prevState, nextState) { - var [ leavingRoutes, enteringRoutes ] = computeDiff(prevState, nextState); - var hooks = getTransitionHooksFromRoutes(leavingRoutes, 'onLeave'); - - hooks.push.apply( - hooks, - getTransitionHooksFromRoutes(enteringRoutes, 'onEnter') - ); - - return hooks; -} - -function getComponentsForRoute(route, callback) { - if (route.component || route.components) { - callback(null, route.component || route.components); - } else if (route.getComponents) { - route.getComponents(callback); - } else { - callback(); - } -} - -/** - * Asynchronously fetches all components needed for the given router - * state and calls callback(error, components) when finished. - * - * Note: This operation may return synchronously if no routes have an - * asynchronous getComponents method. - */ -export function getComponents(routes, callback) { - mapAsync(routes, function (route, index, callback) { - getComponentsForRoute(route, callback); - }, callback); -} - -/** - * Extracts an object of params the given route cares about from - * the given params object. - */ -export function getRouteParams(route, params) { - var routeParams = {}; - - if (!route.path) - return routeParams; - - var paramNames = getParamNames(route.path); - - for (var p in params) - if (params.hasOwnProperty(p) && paramNames.indexOf(p) !== -1) - routeParams[p] = params[p]; - - return routeParams; -} diff --git a/modules/ScrollManagementMixin.js b/modules/ScrollManagementMixin.js index fc5ee5e8f3..bb2539ab85 100644 --- a/modules/ScrollManagementMixin.js +++ b/modules/ScrollManagementMixin.js @@ -1,16 +1,16 @@ import React from 'react'; +import { Actions } from 'history'; import { canUseDOM, setWindowScrollPosition } from './DOMUtils'; -import NavigationTypes from './NavigationTypes'; var { func } = React.PropTypes; -function getCommonAncestors(branch, otherBranch) { - return branch.filter(route => otherBranch.indexOf(route) !== -1); +function getCommonAncestors(routes, otherRoutes) { + return routes.filter(route => otherRoutes.indexOf(route) !== -1); } function shouldUpdateScrollPosition(state, prevState) { - var { location, branch } = state; - var { location: prevLocation, branch: prevBranch } = prevState; + var { location, routes } = state; + var { location: prevLocation, routes: prevRoutes } = prevState; // When an onEnter hook uses transition.to to redirect // on the initial load prevLocation is null, so assume @@ -24,16 +24,16 @@ function shouldUpdateScrollPosition(state, prevState) { // Don't update scroll position if any of the ancestors // has `ignoreScrollPosition` set to `true` on the route. - var sharedAncestors = getCommonAncestors(branch, prevBranch); + var sharedAncestors = getCommonAncestors(routes, prevRoutes); if (sharedAncestors.some(route => route.ignoreScrollBehavior)) return false; return true; } -function updateWindowScrollPosition(navigationType, scrollX, scrollY) { +function updateWindowScrollPosition(action, scrollX, scrollY) { if (canUseDOM) { - if (navigationType === NavigationTypes.POP) { + if (action === Actions.POP) { setWindowScrollPosition(scrollX, scrollY); } else { setWindowScrollPosition(0, 0); @@ -57,11 +57,10 @@ var ScrollManagementMixin = { componentDidUpdate(prevProps, prevState) { var { location } = this.state; - var locationState = location && location.state; - if (locationState && this.props.shouldUpdateScrollPosition(this.state, prevState)) { - var { scrollX, scrollY } = locationState; - this.props.updateScrollPosition(location.navigationType, scrollX || 0, scrollY || 0); + if (location && this.props.shouldUpdateScrollPosition(this.state, prevState)) { + var { action, scrollX, scrollY } = location; + this.props.updateScrollPosition(action, scrollX || 0, scrollY || 0); } } diff --git a/modules/Transition.js b/modules/Transition.js deleted file mode 100644 index 1b5fa89fd6..0000000000 --- a/modules/Transition.js +++ /dev/null @@ -1,21 +0,0 @@ -class Transition { - - constructor() { - this.isCancelled = false; - this.redirectInfo = null; - this.abortReason = null; - } - - to(pathname, query, state) { - this.redirectInfo = { pathname, query, state }; - this.isCancelled = true; - } - - abort(reason) { - this.abortReason = reason; - this.isCancelled = true; - } - -} - -export default Transition; diff --git a/modules/TransitionHook.js b/modules/TransitionHook.js deleted file mode 100644 index a53f1113d4..0000000000 --- a/modules/TransitionHook.js +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import warning from 'warning'; - -var { object } = React.PropTypes; - -var TransitionHook = { - - contextTypes: { - router: object.isRequired - }, - - componentDidMount() { - warning( - typeof this.routerWillLeave === 'function', - 'Components that mixin TransitionHook should have a routerWillLeave method, check %s', - this.constructor.displayName || this.constructor.name - ); - - if (this.routerWillLeave) - this.context.router.addTransitionHook(this.routerWillLeave); - }, - - componentWillUnmount() { - if (this.routerWillLeave) - this.context.router.removeTransitionHook(this.routerWillLeave); - } - -}; - -export default TransitionHook; diff --git a/modules/__tests__/ActiveMixin-test.js b/modules/__tests__/ActiveMixin-test.js new file mode 100644 index 0000000000..729823aad8 --- /dev/null +++ b/modules/__tests__/ActiveMixin-test.js @@ -0,0 +1,120 @@ +import expect from 'expect'; +import React from 'react'; +import createLocation from 'history/lib/createLocation'; +import createHistory from 'history/lib/createMemoryHistory'; +import Router from '../Router'; +import Route from '../Route'; + +describe('ActiveMixin', function () { + + var node; + beforeEach(function () { + node = document.createElement('div'); + }); + + afterEach(function () { + React.unmountComponentAtNode(node); + }); + + describe('a pathname that matches the URL', function () { + describe('with no query', function () { + it('is active', function (done) { + React.render(( + + + + ), node, function () { + expect(this.isActive('/home')).toBe(true); + done(); + }); + }); + }); + + describe('with a query that also matches', function () { + it('is active', function (done) { + React.render(( + + + + ), node, function () { + expect(this.isActive('/home', { the: 'query' })).toBe(true); + done(); + }); + }); + }); + + describe('with a query that does not match', function () { + it('is not active', function (done) { + React.render(( + + + + ), node, function () { + expect(this.isActive('/home', { something: 'else' })).toBe(false); + done(); + }); + }); + }); + }); + + describe('a pathname that matches a parent route, but not the URL directly', function () { + describe('with no query', function () { + it('is active', function (done) { + React.render(( + + + + + + ), node, function () { + expect(this.isActive('/home')).toBe(true); + done(); + }); + }); + }); + + describe('with a query that also matches', function () { + it('is active', function (done) { + React.render(( + + + + + + ), node, function () { + expect(this.isActive('/home', { the: 'query' })).toBe(true); + done(); + }); + }); + }); + + describe('with a query that does not match', function () { + it('is active', function (done) { + React.render(( + + + + + + ), node, function () { + expect(this.isActive('/home', { something: 'else' })).toBe(false); + done(); + }); + }); + }); + }); + + describe('a pathname that matches only the beginning of the URL', function () { + it('is not active', function (done) { + React.render(( + + + + ), node, function () { + expect(this.isActive('/h')).toBe(false); + done(); + }); + }); + }); + +}); diff --git a/modules/__tests__/BrowserHistory-test.js b/modules/__tests__/BrowserHistory-test.js deleted file mode 100644 index 292d563136..0000000000 --- a/modules/__tests__/BrowserHistory-test.js +++ /dev/null @@ -1,7 +0,0 @@ -import describeHistory from './describeHistory'; -import BrowserHistory, { history } from '../BrowserHistory'; - -describe('BrowserHistory', function () { - describeHistory(new BrowserHistory); - describeHistory(history); -}); diff --git a/modules/__tests__/HashHistory-test.js b/modules/__tests__/HashHistory-test.js deleted file mode 100644 index e243209bac..0000000000 --- a/modules/__tests__/HashHistory-test.js +++ /dev/null @@ -1,7 +0,0 @@ -import describeHistory from './describeHistory'; -import HashHistory, { history } from '../HashHistory'; - -describe('HashHistory', function () { - describeHistory(new HashHistory); - describeHistory(history); -}); diff --git a/modules/__tests__/Link-test.js b/modules/__tests__/Link-test.js index e35411a4dd..77b3c515ec 100644 --- a/modules/__tests__/Link-test.js +++ b/modules/__tests__/Link-test.js @@ -1,9 +1,9 @@ import assert from 'assert'; import expect from 'expect'; -import React, { render } from 'react/addons'; +import React from 'react/addons'; +import createHistory from 'history/lib/createMemoryHistory'; +import execSteps from './execSteps'; import Router from '../Router'; -import MemoryHistory from '../MemoryHistory'; -import HashHistory from '../HashHistory'; import Route from '../Route'; import Link from '../Link'; @@ -34,9 +34,9 @@ describe('A ', function () { } }); - var div; + var node; beforeEach(function () { - div = document.createElement('div'); + node = document.createElement('div'); }); it('knows how to make its href', function () { @@ -46,32 +46,34 @@ describe('A ', function () { } }); - render(( - - + React.render(( + + - ), div, function () { - var a = div.querySelector('a'); + ), node, function () { + var a = node.querySelector('a'); expect(a.getAttribute('href')).toEqual('/hello/michael?the=query'); }); }); - it('knows how to make its href with HashHistory', function () { - var LinkWrapper = React.createClass({ - render() { - return Link; - } - }); - - render(( - - - - ), div, function () { - var a = div.querySelector('a'); - expect(a.getAttribute('href')).toEqual('#/hello/michael?the=query'); - }); - }); + // This test needs to be in its own file with beforeEach(resetHash). + // + //it('knows how to make its href with HashHistory', function () { + // var LinkWrapper = React.createClass({ + // render() { + // return Link; + // } + // }); + + // render(( + // + // + // + // ), node, function () { + // var a = node.querySelector('a'); + // expect(a.getAttribute('href')).toEqual('#/hello/michael?the=query'); + // }); + //}); describe('with params', function () { var App = React.createClass({ @@ -86,28 +88,28 @@ describe('A ', function () { }); it('is active when its params match', function (done) { - render(( - + React.render(( + - + - ), div, function () { - var a = div.querySelectorAll('a')[0]; + ), node, function () { + var a = node.querySelectorAll('a')[0]; expect(a.className.trim()).toEqual('active'); done(); }); }); it('is not active when its params do not match', function (done) { - render(( - + React.render(( + - + - ), div, function () { - var a = div.querySelectorAll('a')[1]; + ), node, function () { + var a = node.querySelectorAll('a')[1]; expect(a.className.trim()).toEqual(''); done(); }); @@ -120,7 +122,7 @@ describe('A ', function () { render() { return (
    - Link + Link {this.props.children}
    ); @@ -129,32 +131,25 @@ describe('A ', function () { var a, steps = [ function () { - a = div.querySelector('a'); + a = node.querySelector('a'); expect(a.className).toEqual('dontKillMe'); - this.transitionTo('hello'); + this.transitionTo('/hello'); }, function () { expect(a.className).toEqual('dontKillMe'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(( - + React.render(( + - ), div, execNextStep); + ), node, execNextStep); }); }); @@ -164,7 +159,7 @@ describe('A ', function () { render() { return (
    - Link + Link {this.props.children}
    ); @@ -173,32 +168,25 @@ describe('A ', function () { var a, steps = [ function () { - a = div.querySelector('a'); + a = node.querySelector('a'); expect(a.className).toEqual('dontKillMe'); - this.transitionTo('hello'); + this.transitionTo('/hello'); }, function () { expect(a.className).toEqual('dontKillMe highlight'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(( - + React.render(( + - ), div, execNextStep); + ), node, execNextStep); }); it('has its activeStyle', function (done) { @@ -206,7 +194,7 @@ describe('A ', function () { render() { return (
    - Link + Link {this.props.children}
    ); @@ -215,32 +203,25 @@ describe('A ', function () { var a, steps = [ function () { - a = div.querySelector('a'); + a = node.querySelector('a'); expect(a.style.color).toEqual('white'); - this.transitionTo('hello'); + this.transitionTo('/hello'); }, function () { expect(a.style.color).toEqual('red'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(( - + React.render(( + - ), div, execNextStep); + ), node, execNextStep); }); }); @@ -253,17 +234,17 @@ describe('A ', function () { done(); }, render() { - return Link; + return Link; } }); - render(( - - - + React.render(( + + + - ), div, () => { - click(div.querySelector('a')); + ), node, () => { + click(node.querySelector('a')); }); }); @@ -273,34 +254,27 @@ describe('A ', function () { // just here to make sure click handlers don't prevent it from happening }, render() { - return Link; + return Link; } }); var steps = [ function () { - click(div.querySelector('a'), { button: 0 }); + click(node.querySelector('a'), { button: 0 }); }, function () { - expect(div.innerHTML).toMatch(/Hello/); - done(); + expect(node.innerHTML).toMatch(/Hello/); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(( - - - + React.render(( + + + - ), div, execNextStep); + ), node, execNextStep); }); }); diff --git a/modules/__tests__/Location-test.js b/modules/__tests__/Location-test.js deleted file mode 100644 index 113a5b6c1a..0000000000 --- a/modules/__tests__/Location-test.js +++ /dev/null @@ -1,16 +0,0 @@ -import expect from 'expect'; -import Location from '../Location'; - -describe('Location.isLocation', function () { - it('returns true for Location objects', function () { - expect(Location.isLocation(new Location)).toBe(true); - }); - - it('returns false for other objects', function () { - expect(Location.isLocation('path')).toBe(false); - expect(Location.isLocation(1)).toBe(false); - expect(Location.isLocation(true)).toBe(false); - expect(Location.isLocation(undefined)).toBe(false); - expect(Location.isLocation(null)).toBe(false); - }); -}); diff --git a/modules/__tests__/MemoryHistory-test.js b/modules/__tests__/MemoryHistory-test.js deleted file mode 100644 index c9ef0852c2..0000000000 --- a/modules/__tests__/MemoryHistory-test.js +++ /dev/null @@ -1,88 +0,0 @@ -import expect from 'expect'; -import describeHistory from './describeHistory'; -import MemoryHistory from '../MemoryHistory'; - -describe('MemoryHistory', function () { - describeHistory(new MemoryHistory('/')); - - var history; - beforeEach(function () { - history = new MemoryHistory('/'); - }); - - describe('when first created', function () { - it('cannot go back', function () { - expect(history.canGoBack()).toBe(false); - }); - - it('cannot go forward', function () { - expect(history.canGoForward()).toBe(false); - }); - }); - - describe('when pushing a new path', function () { - beforeEach(function () { - history.pushState(null, '/push'); - }); - - it('increments current index by one', function () { - expect(history.current).toEqual(1); - }); - - it('has the correct path', function () { - expect(history.location.pathname).toEqual('/push'); - }); - - it('can go back', function () { - expect(history.canGoBack()).toBe(true); - }); - - it('cannot go forward', function () { - expect(history.canGoForward()).toBe(false); - }); - - describe('and then replacing that path', function () { - beforeEach(function () { - history.replaceState(null, '/replace'); - }); - - it('maintains the current index', function () { - expect(history.current).toEqual(1); - }); - - it('returns the correct path', function () { - expect(history.location.pathname).toEqual('/replace'); - }); - - it('can go back', function () { - expect(history.canGoBack()).toBe(true); - }); - - it('cannot go forward', function () { - expect(history.canGoForward()).toBe(false); - }); - }); - - describe('and then going back', function () { - beforeEach(function () { - history.back(); - }); - - it('decrements current index by one', function () { - expect(history.current).toEqual(0); - }); - - it('has the correct path', function () { - expect(history.location.pathname).toEqual('/'); - }); - - it('cannot go back', function () { - expect(history.canGoBack()).toBe(false); - }); - - it('can go forward', function () { - expect(history.canGoForward()).toBe(true); - }); - }); - }); -}); diff --git a/modules/__tests__/Redirect-test.js b/modules/__tests__/Redirect-test.js index 6f64df4221..d8f99f1be3 100644 --- a/modules/__tests__/Redirect-test.js +++ b/modules/__tests__/Redirect-test.js @@ -1,9 +1,9 @@ import expect from 'expect'; -import React, { render } from 'react'; -import MemoryHistory from '../MemoryHistory'; +import React from 'react'; +import createHistory from 'history/lib/createMemoryHistory'; +import Redirect from '../Redirect'; import Router from '../Router'; import Route from '../Route'; -import Redirect from '../Redirect'; describe('A ', function () { var node; @@ -16,10 +16,10 @@ describe('A ', function () { }); it('works', function (done) { - render(( - - - + React.render(( + + + ), node, function () { expect(this.state.location.pathname).toEqual('/messages/5'); diff --git a/modules/__tests__/Router-test.js b/modules/__tests__/Router-test.js index 51ee502692..8847ac57ac 100644 --- a/modules/__tests__/Router-test.js +++ b/modules/__tests__/Router-test.js @@ -1,17 +1,18 @@ import expect from 'expect'; -import React, { render } from 'react'; -import MemoryHistory from '../MemoryHistory'; +import React from 'react'; +import { createLocation } from 'history'; import Router from '../Router'; import Route from '../Route'; describe('Router', function () { - var div; + + var node; beforeEach(function () { - div = document.createElement('div'); + node = document.createElement('div'); }); afterEach(function () { - React.unmountComponentAtNode(div); + React.unmountComponentAtNode(node); }); var Parent = React.createClass({ @@ -27,54 +28,54 @@ describe('Router', function () { }); it('renders routes', function (done) { - render(( - - + React.render(( + + - ), div, function () { - expect(div.textContent.trim()).toEqual('parent'); + ), node, function () { + expect(node.textContent.trim()).toEqual('parent'); done(); }); }); it('renders child routes when the parent does not have a path', function (done) { - render(( - + React.render(( + - + - ), div, function () { - expect(div.textContent.trim()).toEqual('parentparentchild'); + ), node, function () { + expect(node.textContent.trim()).toEqual('parentparentchild'); done(); }); }); it('renders nested children correctly', function (done) { - render(( - + React.render(( + - + - ), div, function () { - expect(div.textContent.trim()).toMatch(/parent/); - expect(div.textContent.trim()).toMatch(/child/); + ), node, function () { + expect(node.textContent.trim()).toMatch(/parent/); + expect(node.textContent.trim()).toMatch(/child/); done(); }); }); it('renders the child\'s component when it has no component', function (done) { - render(( - + React.render(( + - + - ), div, function () { - expect(div.textContent.trim()).toMatch(/child/); + ), node, function () { + expect(node.textContent.trim()).toMatch(/child/); done(); }); }); @@ -83,7 +84,7 @@ describe('Router', function () { var Wrapper = React.createClass({ render() { var { Component } = this.props; - return + return } }); @@ -93,12 +94,12 @@ describe('Router', function () { } }); - render(( - }> + React.render(( + }> - ), div, function () { - expect(div.textContent.trim()).toEqual('wrapped'); + ), node, function () { + expect(node.textContent.trim()).toEqual('wrapped'); done(); }); }); diff --git a/modules/__tests__/RoutingUtils-test.js b/modules/__tests__/RoutingUtils-test.js deleted file mode 100644 index c91b03eb77..0000000000 --- a/modules/__tests__/RoutingUtils-test.js +++ /dev/null @@ -1,370 +0,0 @@ -import expect, { spyOn } from 'expect'; -import { getState as _getState } from '../RoutingUtils'; -import Location from '../Location'; - -function getState(routes, pathname, callback) { - return _getState(routes, new Location(pathname), callback); -} - -describe('getState', function () { - var RootRoute, AboutRoute, CoursesRoute, GradesRoute, CourseRoute, CourseGradesRoute, AssignmentRoute, AssignmentsRoute, CatchAllRoute, AccountRoute, AccountIndexRoute, ProfileRoute, ProfileIndexRoute; - beforeEach(function () { - AboutRoute = { - path: 'about' - }; - - GradesRoute = { - path: 'grades' - }; - - CoursesRoute = { - path: 'courses', - getChildRoutes(state, callback) { - callback(null, [ GradesRoute ]); - } - }; - - CourseGradesRoute = { - path: 'grades' - }; - - AssignmentRoute = { - path: 'assignments/:assignmentID' - }; - - AssignmentsRoute = { - path: 'assignments' - }; - - CourseRoute = { - getChildRoutes(state, callback) { - setTimeout(function () { - callback(null, [ CourseGradesRoute, AssignmentRoute, AssignmentsRoute ]); - }, 0); - } - }; - - AccountIndexRoute = {}; - - AccountRoute = { - path: 'account', - indexRoute: AccountIndexRoute - }; - - ProfileIndexRoute = {}; - - ProfileRoute = { - path: 'profile', - getIndexRoute (cb) { - cb(null, ProfileIndexRoute); - } - }; - - CatchAllRoute = { - path: '*' - }; - - RootRoute = { - path: '/', - childRoutes: [ AboutRoute, CoursesRoute, CourseRoute, AccountRoute, ProfileRoute, CatchAllRoute ] - }; - }); - - describe('when the path does not match any route', function () { - it('matches the "catch all" route', function (done) { - getState(RootRoute, '/not-found', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CatchAllRoute ]); - done(); - }); - }); - }); - - describe('when the path matches a route exactly', function () { - it('matches', function (done) { - getState(RootRoute, '/', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute ]); - done(); - }); - }); - }); - - describe('when the path matches a nested route exactly', function () { - it('matches', function (done) { - getState(RootRoute, '/courses', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CoursesRoute ]); - done(); - }); - }); - - it('does not attempt to fetch the nested route\'s child routes', function (done) { - var spy = spyOn(CoursesRoute, 'getChildRoutes'); - - getState(RootRoute, '/courses', function () { - expect(spy.calls.length).toEqual(0); - done(); - }); - }); - - describe('with an index route', function () { - it('matches synchronously', function (done) { - getState(RootRoute, '/account', function (error, state) { - expect(state.branch).toEqual([ RootRoute, AccountRoute, AccountIndexRoute]); - done(); - }); - }); - - it('matches asynchronously', function (done) { - getState(RootRoute, '/profile', function (error, state) { - expect(state.branch).toEqual([ RootRoute, ProfileRoute, ProfileIndexRoute]); - done(); - }); - }); - }); - }); - - describe('when the path matches a nested route with a trailing slash', function () { - it('matches', function (done) { - getState(RootRoute, '/courses/', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CoursesRoute ]); - done(); - }); - }); - - it('does not attempt to fetch the nested route\'s child routes', function (done) { - var spy = spyOn(CoursesRoute, 'getChildRoutes'); - - getState(RootRoute, '/courses/', function () { - expect(spy.calls.length).toEqual(0); - done(); - }); - }); - }); - - describe('when the path matches a deeply nested route', function () { - it('matches', function (done) { - getState(RootRoute, '/courses/grades', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CoursesRoute, GradesRoute ]); - done(); - }); - }); - - it('fetches the nested route\'s child routes', function (done) { - var spy = spyOn(CoursesRoute, 'getChildRoutes').andCallThrough(); - - getState(RootRoute, '/courses/grades', function () { - expect(spy).toHaveBeenCalled(); - done(); - }); - }); - }); - - describe('when the path matches a route with a dynamic segment', function () { - it('stores the value of that segment in params', function (done) { - getState(RootRoute, '/assignments/abc', function (error, state) { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CourseRoute, AssignmentRoute ]); - expect(state.params).toEqual({ assignmentID: 'abc' }); - done(); - }); - }); - }); - - describe('when nested routes are able to be fetched synchronously', function () { - it('matches synchronously', function () { - var error, state; - - getState(RootRoute, '/courses/grades', function (innerError, innerState) { - error = innerError; - state = innerState; - }); - - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CoursesRoute, GradesRoute ]); - }); - }); - - describe('when nested routes must be fetched asynchronously', function () { - it('matches asynchronously', function (done) { - var outerError, outerState; - - getState(RootRoute, '/assignments', function (error, state) { - outerError = error; - outerState = state; - - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.branch).toEqual([ RootRoute, CourseRoute, AssignmentsRoute ]); - done(); - }); - - expect(outerError).toNotExist(); - expect(outerState).toNotExist(); - }); - }); -}); - -describe('Matching params', function () { - function assertParams(routes, pathname, params, callback) { - if (typeof routes === 'string') - routes = [ { path: routes } ]; - - getState(routes, pathname, function (error, state) { - try { - expect(error).toNotExist(); - expect(state).toBeAn('object'); - expect(state.params).toEqual(params); - callback(); - } catch (error) { - callback(error); - } - }); - } - - describe('when a pattern does not have dynamic segments', function () { - var pattern = 'about/us'; - - describe('and the path matches', function () { - it('returns an empty object', function (done) { - assertParams(pattern, '/about/us', {}, done); - }); - }); - }); - - describe('when a pattern has dynamic segments', function () { - var pattern = 'comments/:id.:ext/edit'; - - describe('and the path matches', function () { - it('returns an object with the params', function (done) { - assertParams(pattern, 'comments/abc.js/edit', { id: 'abc', ext: 'js' }, done); - }); - }); - - describe('and the pattern is optional', function () { - var pattern = 'comments/(:id)/edit'; - - describe('and the path matches with supplied param', function () { - it('returns an object with the params', function (done) { - assertParams(pattern, 'comments/123/edit', { id: '123' }, done); - }); - }); - - describe('and the path matches without supplied param', function () { - it('returns an object with an undefined param', function (done) { - assertParams(pattern, 'comments//edit', { id: undefined }, done); - }); - }); - }); - - describe('and the pattern and forward slash are optional', function () { - var pattern = 'comments(/:id)/edit'; - - describe('and the path matches with supplied param', function () { - it('returns an object with the params', function (done) { - assertParams(pattern, 'comments/123/edit', { id: '123' }, done); - }); - }); - - describe('and the path matches without supplied param', function () { - it('returns an object with an undefined param', function (done) { - assertParams(pattern, 'comments/edit', { id: undefined }, done); - }); - }); - }); - - describe('and the path matches with a segment containing a .', function () { - it('returns an object with the params', function (done) { - assertParams(pattern, 'comments/foo.bar/edit', { id: 'foo', ext: 'bar' }, done); - }); - }); - }); - - describe('when a pattern has characters that have special URL encoding', function () { - var pattern = 'one, two'; - - describe('and the path matches', function () { - it('returns an empty object', function (done) { - assertParams(pattern, 'one, two', {}, done); - }); - }); - }); - - describe('when a pattern has dynamic segments and characters that have special URL encoding', function () { - var pattern = '/comments/:id/edit now'; - - describe('and the path matches', function () { - it('returns an object with the params', function (done) { - assertParams(pattern, '/comments/abc/edit now', { id: 'abc' }, done); - }); - }); - }); - - describe('when a pattern has a *', function () { - describe('and the path has a single extension', function () { - it('matches', function (done) { - assertParams('/files/*', '/files/my/photo.jpg', { splat: 'my/photo.jpg' }, done); - }); - }); - - describe('and the path has multiple extensions', function () { - it('matches', function (done) { - assertParams('/files/*', '/files/my/photo.jpg.zip', { splat: 'my/photo.jpg.zip' }, done); - }); - }); - - describe('and an extension', function () { - it('matches', function (done) { - assertParams('/files/*.jpg', '/files/my/photo.jpg', { splat: 'my/photo' }, done); - }); - }); - }); - - describe('when a pattern has an optional group', function () { - var pattern = '/archive(/:name)'; - - describe('and the path contains a param for that group', function () { - it('matches', function (done) { - assertParams(pattern, '/archive/foo', { name: 'foo' }, done); - }); - }); - - describe('and the path does not contain a param for that group', function () { - it('matches', function (done) { - assertParams(pattern, '/archive', { name: undefined }, done); - }); - }); - }); - - describe('when a pattern contains dynamic segments', function () { - var pattern = '/:query/with/:domain'; - - describe('and the first param contains a dot', function () { - it('matches', function (done) { - assertParams(pattern, '/foo.ap/with/foo', { query: 'foo.ap', domain: 'foo' }, done); - }); - }); - - describe('and the second param contains a dot', function () { - it('matches', function (done) { - assertParams(pattern, '/foo/with/foo.app', { query: 'foo', domain: 'foo.app' }, done); - }); - }); - - describe('and both params contain a dot', function () { - it('matches', function (done) { - assertParams(pattern, '/foo.ap/with/foo.app', { query: 'foo.ap', domain: 'foo.app' }, done); - }); - }); - }); -}); diff --git a/modules/__tests__/ScrollManagementMixin-test.js b/modules/__tests__/ScrollManagementMixin-test.js new file mode 100644 index 0000000000..fcd0fb6c82 --- /dev/null +++ b/modules/__tests__/ScrollManagementMixin-test.js @@ -0,0 +1,73 @@ +import expect from 'expect'; +import React from 'react'; +import createHistory from 'history/lib/createHashHistory'; +import resetHash from './resetHash'; +import execStepsWithDelay from './execStepsWithDelay'; +import { getWindowScrollPosition } from '../DOMUtils'; +import Router from '../Router'; +import Route from '../Route'; + +describe.skip('ScrollManagementMixin', function () { + var Home = React.createClass({ + render() { + return

    Yo, this page is huge.

    ; + } + }); + + var Inbox = React.createClass({ + render() { + return

    Yo, this page is huge.

    ; + } + }); + + beforeEach(resetHash); + + var node; + beforeEach(function (done) { + node = document.createElement('div'); + document.body.appendChild(node); + }); + + afterEach(function () { + React.unmountComponentAtNode(node); + document.body.removeChild(node); + }); + + it('correctly updates the window scroll position', function (done) { + var steps = [ + function () { + expect(this.state.location.pathname).toEqual('/'); + + window.scrollTo(200, 200); + expect(getWindowScrollPosition()).toEqual({ x: 200, y: 200 }); + + this.transitionTo('/inbox'); + }, + function () { + expect(this.state.location.pathname).toEqual('/inbox'); + expect(getWindowScrollPosition()).toEqual({ x: 0, y: 0 }); + + this.goBack(); + }, + function () { + expect(this.state.location.pathname).toEqual('/'); + expect(getWindowScrollPosition()).toEqual({ x: 200, y: 200 }); + } + ]; + + var execNextStep = execStepsWithDelay(steps, 10, done); + + // Needs scroll support in the history module + // See https://github.com/rackt/history/issues/17 + var history = createHistory({ + getScrollPosition: getWindowScrollPosition + }); + + React.render(( + + + + + ), node, execNextStep); + }); +}); diff --git a/modules/__tests__/Transition-test.js b/modules/__tests__/Transition-test.js deleted file mode 100644 index 04bd3b9a74..0000000000 --- a/modules/__tests__/Transition-test.js +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'assert'; -import Transition from '../Transition'; - -describe('A new Transition', function () { - - var transition; - beforeEach(function () { - transition = new Transition; - }); - - it('is not cancelled', function () { - assert(!transition.isCancelled); - }); - - describe('that is redirected', function () { - beforeEach(function () { - transition.to('/something/else'); - }); - - it('is cancelled', function () { - assert(transition.isCancelled); - }); - }); - - describe('that is aborted', function () { - beforeEach(function () { - transition.abort(); - }); - - it('is cancelled', function () { - assert(transition.isCancelled); - }); - }); - -}); diff --git a/modules/__tests__/TransitionHook-test.js b/modules/__tests__/TransitionHook-test.js deleted file mode 100644 index d2e2bd6895..0000000000 --- a/modules/__tests__/TransitionHook-test.js +++ /dev/null @@ -1,53 +0,0 @@ -import expect from 'expect'; -import React, { render } from 'react'; -import MemoryHistory from '../MemoryHistory'; -import TransitionHook from '../TransitionHook'; -import Router from '../Router'; -import Route from '../Route'; - -describe('TransitionHook', function () { - it('calls routerWillLeave when the router leaves the current location', function (done) { - var div = document.createElement('div'); - var hookCalled = false; - - var One = React.createClass({ - mixins: [ TransitionHook ], - routerWillLeave() { - hookCalled = true; - }, - render() { - return
    one
    ; - } - }); - - var Two = React.createClass({ - render() { - return
    two
    ; - } - }); - - var steps = [ - function () { - expect(this.state.location.pathname).toEqual('/one'); - expect(div.textContent.trim()).toEqual('one'); - this.transitionTo('/two') - }, - function () { - expect(hookCalled).toBe(true); - expect(this.state.location.pathname).toEqual('/two'); - done(); - } - ]; - - function execNextStep() { - steps.shift().apply(this, arguments); - } - - render(( - - - - - ), div, execNextStep); - }); -}); diff --git a/modules/__tests__/createRoutesFromReactChildren-test.js b/modules/__tests__/createRoutesFromReactChildren-test.js index 5aa7086934..21d1d33ddd 100644 --- a/modules/__tests__/createRoutesFromReactChildren-test.js +++ b/modules/__tests__/createRoutesFromReactChildren-test.js @@ -1,7 +1,7 @@ import expect from 'expect'; import React from 'react'; -import Route from '../Route'; import { createRoutesFromReactChildren } from '../RouteUtils'; +import Route from '../Route'; describe('createRoutesFromReactChildren', function () { diff --git a/modules/__tests__/describeHistory.js b/modules/__tests__/describeHistory.js deleted file mode 100644 index acbe2b8d0f..0000000000 --- a/modules/__tests__/describeHistory.js +++ /dev/null @@ -1,52 +0,0 @@ -import expect, { createSpy, spyOn } from 'expect'; -import History from '../History'; - -export default function describeHistory(history) { - it('is an instanceof History', function () { - expect(history).toBeA(History); - }); - - var RequiredMethods = [ 'pushState', 'replaceState', 'go' ]; - - RequiredMethods.forEach(function (method) { - it('has a ' + method + ' method', function () { - expect(history[method]).toBeA('function'); - }); - }); - - describe('adding/removing a listener', function () { - var pushState, go, pushStateSpy, goSpy; - beforeEach(function () { - // It's a bit tricky to test change listeners properly because - // they are triggered when the URL changes. So we need to stub - // out push/go to only notify listeners ... but we can't make - // assertions on the location because it will be wrong. - pushState = history.pushState; - pushStateSpy = spyOn(history, 'pushState').andCall(history._notifyChange); - - go = history.go; - goSpy = spyOn(history, 'go').andCall(history._notifyChange); - }); - - afterEach(function () { - history.push = pushState; - history.go = go; - }); - - it('works', function () { - var spy = expect.createSpy(function () {}); - - history.addChangeListener(spy); - history.pushState(null, '/home'); // call #1 - expect(pushStateSpy).toHaveBeenCalled(); - - expect(spy.calls.length).toEqual(1); - - history.removeChangeListener(spy) - history.back(); // call #2 - expect(goSpy).toHaveBeenCalled(); - - expect(spy.calls.length).toEqual(1); - }); - }); -} diff --git a/modules/__tests__/execSteps.js b/modules/__tests__/execSteps.js new file mode 100644 index 0000000000..2886aa0075 --- /dev/null +++ b/modules/__tests__/execSteps.js @@ -0,0 +1,20 @@ +function execSteps(steps, done) { + var index = 0; + + return function () { + if (steps.length === 0) { + done(); + } else { + try { + steps[index++].apply(this, arguments); + + if (index === steps.length) + done(); + } catch (error) { + done(error); + } + } + }; +} + +export default execSteps; diff --git a/modules/__tests__/execStepsWithDelay.js b/modules/__tests__/execStepsWithDelay.js new file mode 100644 index 0000000000..9900f111f0 --- /dev/null +++ b/modules/__tests__/execStepsWithDelay.js @@ -0,0 +1,15 @@ +import execSteps from './execSteps'; + +function execStepsWithDelay(steps, delay, done) { + var execNextStep = execSteps(steps, done); + + return function () { + var context = this, args = Array.prototype.slice.call(arguments, 0); + + return setTimeout(function () { + execNextStep.apply(context, args); + }, delay); + }; +} + +export default execStepsWithDelay; diff --git a/modules/__tests__/matchRoutes-test.js b/modules/__tests__/matchRoutes-test.js index 3f1901f751..2b66af8b27 100644 --- a/modules/__tests__/matchRoutes-test.js +++ b/modules/__tests__/matchRoutes-test.js @@ -3,8 +3,8 @@ import expect from 'expect'; import { createLocation } from 'history'; import matchRoutes from '../matchRoutes'; -describe.skip('matchRoutes', function () { - var routes, RootRoute, UsersRoute, UsersIndexRoute, UserRoute, AboutRoute, TeamRoute, ProfileRoute; +describe('matchRoutes', function () { + var routes, RootRoute, UsersRoute, UsersIndexRoute, UserRoute, PostRoute, FilesRoute, AboutRoute, TeamRoute, ProfileRoute, CatchAllRoute; beforeEach(function () { /* @@ -17,6 +17,7 @@ describe.skip('matchRoutes', function () {
    + */ routes = [ RootRoute = { @@ -30,6 +31,9 @@ describe.skip('matchRoutes', function () { childRoutes: [ ProfileRoute = { path: '/profile' + }, + PostRoute = { + path: ':postID' } ] }, @@ -40,8 +44,14 @@ describe.skip('matchRoutes', function () { } ] }, + FilesRoute = { + path: '/files/*/*.jpg' + }, AboutRoute = { path: '/about' + }, + CatchAllRoute = { + path: '*' } ]; }); @@ -68,6 +78,28 @@ describe.skip('matchRoutes', function () { }); }); + describe('when the location matches a deeply nested route with params', function () { + it('matches the correct routes and params', function (done) { + matchRoutes(routes, createLocation('/users/5/abc'), function (error, match) { + assert(match); + expect(match.routes).toEqual([ RootRoute, UsersRoute, UserRoute, PostRoute ]); + expect(match.params).toEqual({ userID: '5', postID: 'abc' }); + done(); + }); + }); + }); + + describe('when the location matches a nested route with multiple splat params', function () { + it('matches the correct routes and params', function (done) { + matchRoutes(routes, createLocation('/files/a/b/c.jpg'), function (error, match) { + assert(match); + expect(match.routes).toEqual([ FilesRoute ]); + expect(match.params).toEqual({ splat: [ 'a', 'b/c' ] }); + done(); + }); + }); + }); + describe('when the location matches an absolute route', function () { it('matches the correct routes', function (done) { matchRoutes(routes, createLocation('/about'), function (error, match) { @@ -77,6 +109,16 @@ describe.skip('matchRoutes', function () { }); }); }); + + describe('when the location does not match any routes', function () { + it('matches the "catch-all" route', function (done) { + matchRoutes(routes, createLocation('/not-found'), function (error, match) { + assert(match); + expect(match.routes).toEqual([ CatchAllRoute ]); + done(); + }); + }); + }); } describe('a synchronous route config', function () { @@ -97,7 +139,7 @@ describe.skip('matchRoutes', function () { matchRoutes(routes, createLocation('/profile'), function (error, match) { assert(match); expect(match.routes).toEqual([ RootRoute, UsersRoute, UserRoute, ProfileRoute ]); - expect(match.params).toEqual({ userID: null }); + expect(match.params).toEqual({}); // no userID param done(); }); }); diff --git a/modules/__tests__/resetHash.js b/modules/__tests__/resetHash.js new file mode 100644 index 0000000000..1a751c500f --- /dev/null +++ b/modules/__tests__/resetHash.js @@ -0,0 +1,10 @@ +function resetHash(done) { + if (window.location.hash !== '') { + window.location.hash = ''; + setTimeout(done, 10); + } else { + done(); + } +} + +export default resetHash; diff --git a/modules/__tests__/routeMatching-test.js b/modules/__tests__/routeMatching-test.js deleted file mode 100644 index c2abdd1716..0000000000 --- a/modules/__tests__/routeMatching-test.js +++ /dev/null @@ -1,144 +0,0 @@ -import expect from 'expect'; -import React, { render } from 'react'; -import MemoryHistory from '../MemoryHistory'; -import Router from '../Router'; -import Route from '../Route'; - -describe('A Router', function () { - var div; - beforeEach(function () { - div = document.createElement('div'); - }); - - afterEach(function () { - React.unmountComponentAtNode(div); - }); - - var Component1 = React.createClass({ render() { return null; } }); - var Component2 = React.createClass({ render() { return null; } }); - var Component3 = React.createClass({ render() { return null; } }); - var Component4 = React.createClass({ render() { return null; } }); - - describe('with a synchronous route config', function () { - var childRoutes = [ - { path: 'two/:name', component: Component2 }, - { path: 'three', - components: { - main: Component3, - sidebar: Component4 - } - } - ]; - - var parentRoute = { - path: '/', - component: Component1, - childRoutes: childRoutes, - }; - - var routes = [ parentRoute ]; - - it('matches the correct components', function (done) { - render(, div, function () { - expect(this.state.components).toEqual([ - parentRoute.component, - childRoutes[0].component - ]); - - done(); - }); - }); - - it('matches named components', function (done) { - render(, div, function () { - expect(this.state.components).toEqual([ - Component1, - { main: Component3, sidebar: Component4 } - ]); - - done(); - }); - }); - - it('matches the correct route branch', function (done) { - render(, div, function () { - expect(this.state.branch).toEqual([ - parentRoute, - childRoutes[1] - ]); - - done(); - }); - }); - - it('matches the correct params', function (done) { - render(, div, function () { - expect(this.state.params).toEqual({ name: 'sally' }); - done(); - }); - }); - }); - - describe('with an asynchronous route config', function () { - var childRoutes = [ - { path: 'two/:name', getComponents (cb){ cb(null, Component2); } }, - { - path: 'three', - getComponents (cb) { - cb(null, { - main: Component3, - sidebar: Component4 - }); - } - } - ]; - - var parentRoute = { - path: '/', - getChildRoutes (state, cb) { - cb(null, childRoutes); - }, - getComponents (cb) { - cb(null, Component1); - } - }; - - var routes = [ parentRoute ]; - - it('matches the correct components', function (done) { - render(, div, function () { - expect(this.state.components).toEqual([ Component1, Component2 ]); - done(); - }); - }); - - it('matches named components', function (done) { - render(, div, function () { - expect(this.state.components).toEqual([ - Component1, - { main: Component3, sidebar: Component4 } - ]); - done(); - }); - }); - - it('matches the correct route branch', function (done) { - render(, div, function () { - expect(this.state.branch).toEqual([ - parentRoute, - childRoutes[1] - ]); - - done(); - }); - }); - - it('matches the correct params', function (done) { - render(, div, function () { - expect(this.state.params).toEqual({ name: 'sally' }); - done(); - }); - }); - }); - -}); diff --git a/modules/__tests__/scrollManagement-test.js b/modules/__tests__/scrollManagement-test.js deleted file mode 100644 index 410b6d91b4..0000000000 --- a/modules/__tests__/scrollManagement-test.js +++ /dev/null @@ -1,83 +0,0 @@ -import expect from 'expect'; -import React, { render } from 'react'; -import HashHistory from '../HashHistory'; -import { getWindowScrollPosition } from '../DOMUtils'; -import Router from '../Router'; -import Route from '../Route'; - -describe('Scroll management', function () { - var node, Home, Inbox; - beforeEach(function (done) { - node = document.createElement('div'); - document.body.appendChild(node); - - Home = React.createClass({ - render() { - return ( -
    - {/* make it scroll baby */} -

    Yo, this is the home page.

    -
    - ); - } - }); - - Inbox = React.createClass({ - render() { - return

    This is the inbox.

    ; - } - }); - - window.location.hash = '/'; - - // Give the DOM a little time to reflect the hashchange. - setTimeout(done, 10); - }); - - afterEach(function () { - React.unmountComponentAtNode(node); - document.body.removeChild(node); - }); - - it('correctly updates the window scroll position', function (done) { - var steps = [ - function () { - expect(this.state.location.pathname).toEqual('/'); - window.scrollTo(100, 100); - expect(getWindowScrollPosition()).toEqual({ scrollX: 100, scrollY: 100 }); - this.transitionTo('/inbox'); - }, - function () { - expect(this.state.location.pathname).toEqual('/inbox'); - expect(getWindowScrollPosition()).toEqual({ scrollX: 0, scrollY: 0 }); - this.goBack(); - }, - function () { - expect(this.state.location.pathname).toEqual('/'); - expect(getWindowScrollPosition()).toEqual({ scrollX: 100, scrollY: 100 }); - done(); - } - ]; - - function execNextStep() { - if (steps.length < 1){ - done(); - return; - } - - // Give the DOM a little time to reflect the hashchange. - setTimeout(() => { - steps.shift().call(this); - }, 10); - } - - var history = new HashHistory({ queryKey: true }); - - render(( - - - - - ), node, execNextStep); - }); -}); diff --git a/modules/__tests__/serverRendering-test.js b/modules/__tests__/serverRendering-test.js index f01c68611b..4ff9cc1fff 100644 --- a/modules/__tests__/serverRendering-test.js +++ b/modules/__tests__/serverRendering-test.js @@ -1,64 +1,39 @@ import expect from 'expect'; -import React, { createClass, renderToString } from 'react'; -import Location from '../Location'; +import React from 'react'; +import { createLocation } from 'history'; import Router from '../Router'; import Link from '../Link'; -describe('Server rendering', function () { - var Dashboard, Inbox, DashboardRoute, InboxRoute, RedirectToInboxRoute, routes; +describe('server rendering', function () { + var Dashboard, DashboardRoute, routes; beforeEach(function () { - Dashboard = createClass({ + Dashboard = React.createClass({ render() { return (

    The Dashboard

    - {this.props.children}
    ); } }); - - Inbox = createClass({ - render() { - return
    Inbox Go to the dashboard
    ; - } - }); - - DashboardRoute = { - component: Dashboard, - getChildRoutes(locationState, callback) { - setTimeout(function () { - callback(null, [ InboxRoute, RedirectToInboxRoute ]); - }, 0); - } - }; - InboxRoute = { - path: 'inbox', - component: Inbox + DashboardRoute = { + path: '/', + component: Dashboard }; - RedirectToInboxRoute = { - path: 'redirect-to-inbox', - onEnter(nextState, transition) { - transition.to('/inbox'); - } - }; - routes = [ DashboardRoute ]; }); - + it('works', function (done) { - var location = new Location('/inbox'); + var location = createLocation('/'); Router.run(routes, location, function (error, state, transition) { - var string = renderToString(); - expect(string).toMatch(/Dashboard/); - expect(string).toMatch(/Inbox/); + var string = React.renderToString(); + expect(string).toMatch(/The Dashboard/); done(); }); }); - }); diff --git a/modules/__tests__/transitionHooks-test.js b/modules/__tests__/transitionHooks-test.js index 8bcf78d531..d60b4d44ae 100644 --- a/modules/__tests__/transitionHooks-test.js +++ b/modules/__tests__/transitionHooks-test.js @@ -1,15 +1,16 @@ import expect, { spyOn } from 'expect'; -import React, { render, createClass } from 'react'; -import MemoryHistory from '../MemoryHistory'; +import React from 'react'; +import createHistory from 'history/lib/createMemoryHistory'; +import execSteps from './execSteps'; import Router from '../Router'; import Route from '../Route'; describe('When a router enters a branch', function () { - var div, Dashboard, NewsFeed, Inbox, DashboardRoute, NewsFeedRoute, InboxRoute, RedirectToInboxRoute, MessageRoute, routes; + var node, Dashboard, NewsFeed, Inbox, DashboardRoute, NewsFeedRoute, InboxRoute, RedirectToInboxRoute, MessageRoute, routes; beforeEach(function () { - div = document.createElement('div'); + node = document.createElement('div'); - Dashboard = createClass({ + Dashboard = React.createClass({ render() { return (
    @@ -20,13 +21,13 @@ describe('When a router enters a branch', function () { } }); - NewsFeed = createClass({ + NewsFeed = React.createClass({ render() { return
    News
    ; } }); - Inbox = createClass({ + Inbox = React.createClass({ render() { return
    Inbox
    ; } @@ -35,65 +36,65 @@ describe('When a router enters a branch', function () { NewsFeedRoute = { path: 'news', component: NewsFeed, - onEnter(nextState, transition) { - expect(nextState.branch).toContain(NewsFeedRoute); - expect(transition).toBeAn('object'); + onEnter(nextState, redirectTo) { + expect(nextState.routes).toContain(NewsFeedRoute); + expect(redirectTo).toBeA('function'); }, - onLeave(nextState, transition) { - expect(nextState.branch).toNotContain(NewsFeedRoute); - expect(transition).toBeAn('object'); + onLeave(nextState, redirectTo) { + expect(nextState.routes).toNotContain(NewsFeedRoute); + expect(redirectTo).toBeA('function'); } }; InboxRoute = { path: 'inbox', component: Inbox, - onEnter(nextState, transition) { - expect(nextState.branch).toContain(InboxRoute); - expect(transition).toBeAn('object'); + onEnter(nextState, redirectTo) { + expect(nextState.routes).toContain(InboxRoute); + expect(redirectTo).toBeA('function'); }, - onLeave(nextState, transition) { - expect(nextState.branch).toNotContain(InboxRoute); - expect(transition).toBeAn('object'); + onLeave(nextState, redirectTo) { + expect(nextState.routes).toNotContain(InboxRoute); + expect(redirectTo).toBeA('function'); } }; RedirectToInboxRoute = { path: 'redirect-to-inbox', - onEnter(nextState, transition) { - expect(nextState.branch).toContain(RedirectToInboxRoute); - expect(transition).toBeAn('object'); + onEnter(nextState, redirectTo) { + expect(nextState.routes).toContain(RedirectToInboxRoute); + expect(redirectTo).toBeA('function'); - transition.to('/inbox'); + redirectTo('/inbox'); }, - onLeave(nextState, transition) { - expect(nextState.branch).toNotContain(RedirectToInboxRoute); - expect(transition).toBeAn('object'); + onLeave(nextState, redirectTo) { + expect(nextState.routes).toNotContain(RedirectToInboxRoute); + expect(redirectTo).toBeA('function'); } }; MessageRoute = { path: 'messages/:messageID', - onEnter(nextState, transition) { - expect(nextState.branch).toContain(MessageRoute); - expect(transition).toBeAn('object'); + onEnter(nextState, redirectTo) { + expect(nextState.routes).toContain(MessageRoute); + expect(redirectTo).toBeA('function'); }, - onLeave(nextState, transition) { + onLeave(nextState, redirectTo) { // We can't make this assertion when switching from /messages/123 => /messages/456 - //expect(nextState.branch).toNotContain(MessageRoute); - expect(transition).toBeAn('object'); + //expect(nextState.routes).toNotContain(MessageRoute); + expect(redirectTo).toBeA('function'); } }; DashboardRoute = { component: Dashboard, - onEnter(nextState, transition) { - expect(nextState.branch).toContain(DashboardRoute); - expect(transition).toBeAn('object'); + onEnter(nextState, redirectTo) { + expect(nextState.routes).toContain(DashboardRoute); + expect(redirectTo).toBeA('function'); }, - onLeave(nextState, transition) { - expect(nextState.branch).toNotContain(DashboardRoute); - expect(transition).toBeAn('object'); + onLeave(nextState, redirectTo) { + expect(nextState.routes).toNotContain(DashboardRoute); + expect(redirectTo).toBeA('function'); }, childRoutes: [ NewsFeedRoute, InboxRoute, RedirectToInboxRoute, MessageRoute ] }; @@ -102,12 +103,16 @@ describe('When a router enters a branch', function () { DashboardRoute ]; }); + + afterEach(function () { + React.unmountComponentAtNode(node); + }); it('calls the onEnter hooks of all routes in that branch', function (done) { var dashboardRouteEnterSpy = spyOn(DashboardRoute, 'onEnter').andCallThrough(); var newsFeedRouteEnterSpy = spyOn(NewsFeedRoute, 'onEnter').andCallThrough(); - render(, div, function () { + React.render(, node, function () { expect(dashboardRouteEnterSpy).toHaveBeenCalled(); expect(newsFeedRouteEnterSpy).toHaveBeenCalled(); done(); @@ -120,7 +125,7 @@ describe('When a router enters a branch', function () { var redirectRouteLeaveSpy = spyOn(RedirectToInboxRoute, 'onLeave').andCallThrough(); var inboxEnterSpy = spyOn(InboxRoute, 'onEnter').andCallThrough(); - render(, div, function () { + React.render(, node, function () { expect(this.state.location.pathname).toEqual('/inbox'); expect(redirectRouteEnterSpy).toHaveBeenCalled(); expect(redirectRouteLeaveSpy.calls.length).toEqual(0); @@ -144,19 +149,16 @@ describe('When a router enters a branch', function () { function () { expect(inboxRouteLeaveSpy).toHaveBeenCalled('InboxRoute.onLeave was not called'); expect(dashboardRouteLeaveSpy.calls.length).toEqual(0, 'DashboardRoute.onLeave was called'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(, div, execNextStep); + React.render( + , node, execNextStep); }); }); @@ -177,20 +179,16 @@ describe('When a router enters a branch', function () { expect(messageRouteLeaveSpy).toHaveBeenCalled('MessageRoute.onLeave was not called'); expect(messageRouteEnterSpy).toHaveBeenCalled('MessageRoute.onEnter was not called'); expect(dashboardRouteLeaveSpy.calls.length).toEqual(0, 'DashboardRoute.onLeave was called'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); - render(, div, execNextStep); + React.render( + , node, execNextStep); }); }); }); - diff --git a/modules/__tests__/transitions-test.js b/modules/__tests__/transitionTo-test.js similarity index 75% rename from modules/__tests__/transitions-test.js rename to modules/__tests__/transitionTo-test.js index b18903c15c..2fdc1f6f29 100644 --- a/modules/__tests__/transitions-test.js +++ b/modules/__tests__/transitionTo-test.js @@ -1,10 +1,14 @@ import expect from 'expect'; -import React, { render } from 'react'; -import MemoryHistory from '../MemoryHistory'; +import React from 'react'; +import createHistory from 'history/lib/createHashHistory'; +import resetHash from './resetHash'; +import execSteps from './execSteps'; import Router from '../Router'; import Route from '../Route'; describe('transitionTo', function () { + beforeEach(resetHash); + var node; beforeEach(function () { node = document.createElement('div'); @@ -35,20 +39,14 @@ describe('transitionTo', function () { }, function () { expect(this.state.location.pathname).toEqual('/home/hi:there'); - done(); } ]; - function execNextStep() { - try { - steps.shift().apply(this, arguments); - } catch (error) { - done(error); - } - } + var execNextStep = execSteps(steps, done); + var history = createHistory(); - render(( - + React.render(( + diff --git a/modules/getComponents.js b/modules/getComponents.js new file mode 100644 index 0000000000..38d23d1f52 --- /dev/null +++ b/modules/getComponents.js @@ -0,0 +1,26 @@ +import { mapAsync } from './AsyncUtils'; + +function getComponentsForRoute(route, callback) { + if (route.component || route.components) { + callback(null, route.component || route.components); + } else if (route.getComponents) { + route.getComponents(callback); + } else { + callback(); + } +} + +/** + * Asynchronously fetches all components needed for the given router + * state and calls callback(error, components) when finished. + * + * Note: This operation may finish synchronously if no routes have an + * asynchronous getComponents method. + */ +function getComponents(nextState, callback) { + mapAsync(nextState.routes, function (route, index, callback) { + getComponentsForRoute(route, callback); + }, callback); +} + +export default getComponents; diff --git a/modules/getRouteParams.js b/modules/getRouteParams.js new file mode 100644 index 0000000000..d363971a98 --- /dev/null +++ b/modules/getRouteParams.js @@ -0,0 +1,22 @@ +import { getParamNames } from './PatternUtils'; + +/** + * Extracts an object of params the given route cares about from + * the given params object. + */ +function getRouteParams(route, params) { + var routeParams = {}; + + if (!route.path) + return routeParams; + + var paramNames = getParamNames(route.path); + + for (var p in params) + if (params.hasOwnProperty(p) && paramNames.indexOf(p) !== -1) + routeParams[p] = params[p]; + + return routeParams; +} + +export default getRouteParams; diff --git a/modules/index.js b/modules/index.js index 693f7a75b2..34a33fd620 100644 --- a/modules/index.js +++ b/modules/index.js @@ -8,7 +8,6 @@ export Route from './Route'; /* mixins */ export Navigation from './Navigation'; -export TransitionHook from './TransitionHook'; export State from './State'; /* utils */ diff --git a/modules/matchRoutes.js b/modules/matchRoutes.js index 267412869f..059350d14e 100644 --- a/modules/matchRoutes.js +++ b/modules/matchRoutes.js @@ -51,17 +51,19 @@ function matchRouteDeep(basename, route, location, callback) { var isExactMatch = remainingPathname === ''; if (isExactMatch && route.path) { - var routes = [ route ]; - var params = createParams(paramNames, paramValues); + var match = { + routes: [ route ], + params: createParams(paramNames, paramValues) + }; getIndexRoute(route, location, function (error, indexRoute) { if (error) { callback(error); } else { if (indexRoute) - routes.push(indexRoute); + match.routes.push(indexRoute); - callback(null, { routes, params }); + callback(null, match); } }); } else if (remainingPathname != null || route.childRoutes) { @@ -79,8 +81,6 @@ function matchRouteDeep(basename, route, location, callback) { } else if (match) { // A child route matched! Augment the match and pass it up the stack. match.routes.unshift(route); - assignParams(match.params, paramNames, paramValues); - callback(null, match); } else { callback(); @@ -97,13 +97,13 @@ function matchRouteDeep(basename, route, location, callback) { /** * Asynchronously matches the given location to a set of routes and calls - * callback(error, state) when finished. The state object may have the + * callback(error, state) when finished. The state object will have the * following properties: * * - routes An array of routes that matched, in hierarchical order * - params An object of URL parameters * - * Note: This operation may return synchronously if no routes have an + * Note: This operation may finish synchronously if no routes have an * asynchronous getChildRoutes method. */ function matchRoutes(routes, location, callback, basename='') { diff --git a/modules/runTransitionHooks.js b/modules/runTransitionHooks.js new file mode 100644 index 0000000000..04399d4510 --- /dev/null +++ b/modules/runTransitionHooks.js @@ -0,0 +1,102 @@ +import { loopAsync } from './AsyncUtils'; +import { getParamNames } from './PatternUtils'; + +function routeParamsChanged(route, prevState, nextState) { + if (!route.path) + return false; + + var paramNames = getParamNames(route.path); + + return paramNames.some(function (paramName) { + return prevState.params[paramName] !== nextState.params[paramName]; + }); +} + +function createTransitionHook(fn, context) { + return function (a, b, callback) { + fn.apply(context, arguments); + + if (fn.length < 3) { + // Assume fn executes synchronously and + // automatically call the callback. + callback(); + } + }; +} + +function getTransitionHooksFromRoutes(routes, hookName) { + return routes.reduce(function (hooks, route) { + if (route[hookName]) + hooks.push(createTransitionHook(route[hookName], route)); + + return hooks; + }, []); +} + +function getTransitionHooks(prevState, nextState) { + var prevRoutes = prevState && prevState.routes; + var nextRoutes = nextState.routes; + + var leaveRoutes, enterRoutes; + if (prevRoutes) { + leaveRoutes = prevRoutes.filter(function (route) { + return nextRoutes.indexOf(route) === -1 || routeParamsChanged(route, prevState, nextState); + }); + + // onLeave hooks start at the leaf route. + leaveRoutes.reverse(); + + enterRoutes = nextRoutes.filter(function (route) { + return prevRoutes.indexOf(route) === -1 || leaveRoutes.indexOf(route) !== -1; + }); + } else { + leaveRoutes = []; + enterRoutes = nextRoutes; + } + + var hooks = getTransitionHooksFromRoutes(leaveRoutes, 'onLeave'); + + hooks.push.apply( + hooks, + getTransitionHooksFromRoutes(enterRoutes, 'onEnter') + ); + + return hooks; +} + +/** + * Compiles and runs an array of transition hook functions that + * should be called before we transition to a new state. Transition + * hook signatures are: + * + * - route.onEnter(nextState, redirectTo[, callback ]) + * - route.onLeave(nextState, redirectTo[, callback ]) + * + * Transition hooks run in order from the leaf route in the branch + * we're leaving, up the tree to the common parent route, and back + * down the branch we're entering to the leaf route. + * + * If a transition hook needs to execute asynchronously it may have + * a 3rd argument that it should call when it is finished. Otherwise + * the transition executes synchronously. + */ +function runTransitionHooks(prevState, nextState, callback) { + var hooks = getTransitionHooks(prevState, nextState); + + var redirectInfo; + function redirectTo(pathname, query, state) { + redirectInfo = { pathname, query, state }; + } + + loopAsync(hooks.length, (index, next, done) => { + hooks[index](nextState, redirectTo, (error) => { + if (error || redirectInfo) { + done(error, redirectInfo); // No need to continue. + } else { + next(); + } + }); + }, callback); +} + +export default runTransitionHooks; diff --git a/package.json b/package.json index c08135b92f..00095f5713 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "license": "MIT", "dependencies": { - "history": "^1.0.0", + "history": "^1.1.0", "invariant": "^2.0.0", "keymirror": "^0.1.1", "qs": "2.4.1",