From ed0cf625dbddd3520e3bb7bc99b92f04df0c5805 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Sun, 5 Oct 2014 22:40:13 -0700 Subject: [PATCH] Yet another large refactoring (hooray!) [added] Navigation mixin for components that need to modify the URL [added] CurrentPath mixin for components that need to know the current URL path [added] getActiveRoutes, getActiveParams, and getActiveQuery methods to ActiveState mixin [removed] Awkward updateActiveState callback from ActiveState mixin [removed] Router.PathState (use Router.CurrentPath instead) [removed] Router.Transitions (use Router.Navigation instead) [removed] Router.RouteLookup (because it was useless) [added] as an alternative to scrollBehavior="imitateBrowser" [changed] => . Currently only used in testing, but will be useful for SSR [added] but starting to think the hooks should be private --- modules/actions/LocationActions.js | 7 +- modules/behaviors/ImitateBrowserBehavior.js | 23 + modules/behaviors/ScrollToTopBehavior.js | 13 + modules/components/Link.js | 32 +- modules/components/Routes.js | 463 +++++++++++++++++- .../components/__tests__/DefaultRoute-test.js | 49 +- modules/components/__tests__/Link-test.js | 46 +- .../__tests__/NotFoundRoute-test.js | 49 +- modules/components/__tests__/Routes-test.js | 34 +- modules/index.js | 7 +- modules/locations/HashLocation.js | 3 +- .../{ActiveDelegate.js => ActiveContext.js} | 56 ++- modules/mixins/ActiveState.js | 71 +-- modules/mixins/ChangeEmitter.js | 50 -- modules/mixins/CurrentPath.js | 32 ++ modules/mixins/LocationContext.js | 86 ++++ .../mixins/{PathDelegate.js => Navigation.js} | 66 +-- modules/mixins/PathState.js | 120 ----- .../{RouteContainer.js => RouteContext.js} | 32 +- modules/mixins/RouteLookup.js | 36 -- modules/mixins/ScrollContext.js | 76 +++ modules/mixins/ScrollDelegate.js | 39 -- modules/mixins/ScrollState.js | 107 ---- modules/mixins/TransitionHandler.js | 425 ---------------- modules/mixins/Transitions.js | 49 -- ...Delegate-test.js => ActiveContext-test.js} | 9 +- ...hState-test.js => LocationContext-test.js} | 18 +- ...athDelegate-test.js => Navigation-test.js} | 47 +- ...Container-test.js => RouteContext-test.js} | 20 +- .../mixins/__tests__/ScrollContext-test.js | 85 ++++ modules/stores/PathStore.js | 3 + modules/stores/ScrollStore.js | 79 +++ modules/stores/__tests__/PathStore-test.js | 2 + tests.js | 22 +- 34 files changed, 1146 insertions(+), 1110 deletions(-) create mode 100644 modules/behaviors/ImitateBrowserBehavior.js create mode 100644 modules/behaviors/ScrollToTopBehavior.js rename modules/mixins/{ActiveDelegate.js => ActiveContext.js} (62%) delete mode 100644 modules/mixins/ChangeEmitter.js create mode 100644 modules/mixins/CurrentPath.js create mode 100644 modules/mixins/LocationContext.js rename modules/mixins/{PathDelegate.js => Navigation.js} (53%) delete mode 100644 modules/mixins/PathState.js rename modules/mixins/{RouteContainer.js => RouteContext.js} (92%) delete mode 100644 modules/mixins/RouteLookup.js create mode 100644 modules/mixins/ScrollContext.js delete mode 100644 modules/mixins/ScrollDelegate.js delete mode 100644 modules/mixins/ScrollState.js delete mode 100644 modules/mixins/TransitionHandler.js delete mode 100644 modules/mixins/Transitions.js rename modules/mixins/__tests__/{ActiveDelegate-test.js => ActiveContext-test.js} (94%) rename modules/mixins/__tests__/{PathState-test.js => LocationContext-test.js} (78%) rename modules/mixins/__tests__/{PathDelegate-test.js => Navigation-test.js} (55%) rename modules/mixins/__tests__/{RouteContainer-test.js => RouteContext-test.js} (91%) create mode 100644 modules/mixins/__tests__/ScrollContext-test.js create mode 100644 modules/stores/ScrollStore.js diff --git a/modules/actions/LocationActions.js b/modules/actions/LocationActions.js index 46a1be6634..cd591d0aad 100644 --- a/modules/actions/LocationActions.js +++ b/modules/actions/LocationActions.js @@ -21,7 +21,12 @@ var LocationActions = { /** * Indicates the most recent entry should be removed from the history stack. */ - POP: 'pop' + POP: 'pop', + + /** + * Indicates that a route transition is finished. + */ + FINISHED_TRANSITION: 'finished-transition' }; diff --git a/modules/behaviors/ImitateBrowserBehavior.js b/modules/behaviors/ImitateBrowserBehavior.js new file mode 100644 index 0000000000..accaa562b4 --- /dev/null +++ b/modules/behaviors/ImitateBrowserBehavior.js @@ -0,0 +1,23 @@ +var LocationActions = require('../actions/LocationActions'); + +/** + * A scroll behavior that attempts to imitate the default behavior + * of modern browsers. + */ +var ImitateBrowserBehavior = { + + updateScrollPosition: function (position, actionType) { + switch (actionType) { + case LocationActions.PUSH: + case LocationActions.REPLACE: + window.scrollTo(0, 0); + break; + case LocationActions.POP: + window.scrollTo(position.x, position.y); + break; + } + } + +}; + +module.exports = ImitateBrowserBehavior; diff --git a/modules/behaviors/ScrollToTopBehavior.js b/modules/behaviors/ScrollToTopBehavior.js new file mode 100644 index 0000000000..62be29f766 --- /dev/null +++ b/modules/behaviors/ScrollToTopBehavior.js @@ -0,0 +1,13 @@ +/** + * A scroll behavior that always scrolls to the top of the page + * after a transition. + */ +var ScrollToTopBehavior = { + + updateScrollPosition: function () { + window.scrollTo(0, 0); + } + +}; + +module.exports = ScrollToTopBehavior; diff --git a/modules/components/Link.js b/modules/components/Link.js index 965c166bd3..bc3b869b66 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,7 +1,7 @@ var React = require('react'); var merge = require('react/lib/merge'); var ActiveState = require('../mixins/ActiveState'); -var Transitions = require('../mixins/Transitions'); +var Navigation = require('../mixins/Navigation'); function isLeftClickEvent(event) { return event.button === 0; @@ -33,11 +33,11 @@ var Link = React.createClass({ displayName: 'Link', - mixins: [ ActiveState, Transitions ], + mixins: [ ActiveState, Navigation ], propTypes: { - to: React.PropTypes.string.isRequired, activeClassName: React.PropTypes.string.isRequired, + to: React.PropTypes.string.isRequired, params: React.PropTypes.object, query: React.PropTypes.object, onClick: React.PropTypes.func @@ -49,35 +49,17 @@ var Link = React.createClass({ }; }, - getInitialState: function () { - return { - isActive: false - }; - }, - - updateActiveState: function () { - this.setState({ - isActive: this.isActive(this.props.to, this.props.params, this.props.query) - }); - }, - - componentWillReceiveProps: function (nextProps) { - this.setState({ - isActive: this.isActive(nextProps.to, nextProps.params, nextProps.query) - }); - }, - handleClick: function (event) { var allowTransition = true; - var onClickResult; + var clickResult; if (this.props.onClick) - onClickResult = this.props.onClick(event); + clickResult = this.props.onClick(event); if (isModifiedEvent(event) || !isLeftClickEvent(event)) return; - if (onClickResult === false || event.defaultPrevented === true) + if (clickResult === false || event.defaultPrevented === true) allowTransition = false; event.preventDefault(); @@ -100,7 +82,7 @@ var Link = React.createClass({ getClassName: function () { var className = this.props.className || ''; - if (this.state.isActive) + if (this.isActive(this.props.to, this.props.params, this.props.query)) className += ' ' + this.props.activeClassName; return className; diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 6296d8a747..d17035d9d6 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -1,5 +1,321 @@ var React = require('react'); -var TransitionHandler = require('../mixins/TransitionHandler'); +var warning = require('react/lib/warning'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var copyProperties = require('react/lib/copyProperties'); +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var PathStore = require('../stores/PathStore'); +var ScrollStore = require('../stores/ScrollStore'); +var reversedArray = require('../utils/reversedArray'); +var Transition = require('../utils/Transition'); +var Redirect = require('../utils/Redirect'); +var Path = require('../utils/Path'); +var Route = require('./Route'); + +function makeMatch(route, params) { + return { route: route, params: params }; +} + +function getRootMatch(matches) { + return matches[matches.length - 1]; +} + +function findMatches(path, routes, defaultRoute, notFoundRoute) { + var matches = null, route, params; + + for (var i = 0, len = routes.length; i < len; ++i) { + route = routes[i]; + + // Check the subtree first to find the most deeply-nested match. + matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); + + if (matches != null) { + var rootParams = getRootMatch(matches).params; + + params = route.props.paramNames.reduce(function (params, paramName) { + params[paramName] = rootParams[paramName]; + return params; + }, {}); + + matches.unshift(makeMatch(route, params)); + + return matches; + } + + // No routes in the subtree matched, so check this route. + params = Path.extractParams(route.props.path, path); + + if (params) + return [ makeMatch(route, params) ]; + } + + // No routes matched, so try the default route if there is one. + if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) + return [ makeMatch(defaultRoute, params) ]; + + // Last attempt: does the "not found" route match? + if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) + return [ makeMatch(notFoundRoute, params) ]; + + return matches; +} + +function hasMatch(matches, match) { + return matches.some(function (m) { + if (m.route !== match.route) + return false; + + for (var property in m.params) + if (m.params[property] !== match.params[property]) + return false; + + return true; + }); +} + +function updateMatchComponents(matches, refs) { + var i = 0, component; + while (component = refs.__activeRoute__) { + matches[i++].component = component; + refs = component.refs; + } +} + +/** + * Computes the next state for the given component and calls + * callback(error, nextState) when finished. Also runs all + * transition hooks along the way. + */ +function computeNextState(component, transition, callback) { + if (component.state.path === transition.path) + return callback(); // Nothing to do! + + var currentMatches = component.state.matches; + var nextMatches = component.match(transition.path); + + warning( + nextMatches, + 'No route matches path "' + transition.path + '". Make sure you have ' + + ' somewhere in your routes' + ); + + if (!nextMatches) + nextMatches = []; + + var fromMatches, toMatches; + if (currentMatches.length) { + updateMatchComponents(currentMatches, component.refs); + + fromMatches = currentMatches.filter(function (match) { + return !hasMatch(nextMatches, match); + }); + + toMatches = nextMatches.filter(function (match) { + return !hasMatch(currentMatches, match); + }); + } else { + fromMatches = []; + toMatches = nextMatches; + } + + var query = Path.extractQuery(transition.path) || {}; + + runTransitionFromHooks(fromMatches, transition, function (error) { + if (error || transition.isAborted) + return callback(error); + + runTransitionToHooks(toMatches, transition, query, function (error) { + if (error || transition.isAborted) + return callback(error); + + var matches = currentMatches.slice(0, currentMatches.length - fromMatches.length).concat(toMatches); + var rootMatch = getRootMatch(matches); + var params = (rootMatch && rootMatch.params) || {}; + var routes = matches.map(function (match) { + return match.route; + }); + + callback(null, { + path: transition.path, + matches: matches, + activeRoutes: routes, + activeParams: params, + activeQuery: query + }); + }); + }); +} + +/** + * Calls the willTransitionFrom hook of all handlers in the given matches + * serially in reverse with the transition object and the current instance of + * the route's handler, so that the deepest nested handlers are called first. + * Calls callback(error) when finished. + */ +function runTransitionFromHooks(matches, transition, callback) { + var hooks = reversedArray(matches).map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionFrom) + return handler.willTransitionFrom(transition, match.component); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Calls the willTransitionTo hook of all handlers in the given matches + * serially with the transition object and any params that apply to that + * handler. Calls callback(error) when finished. + */ +function runTransitionToHooks(matches, transition, query, callback) { + var hooks = matches.map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionTo) + handler.willTransitionTo(transition, match.params, query); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Runs all hook functions serially and calls callback(error) when finished. + * A hook may return a promise if it needs to execute asynchronously. + */ +function runHooks(hooks, callback) { + try { + var promise = hooks.reduce(function (promise, hook) { + // The first hook to use transition.wait makes the rest + // of the transition async from that point forward. + return promise ? promise.then(hook) : hook(); + }, null); + } catch (error) { + return callback(error); // Sync error. + } + + if (promise) { + // Use setTimeout to break the promise chain. + promise.then(function () { + setTimeout(callback); + }, function (error) { + setTimeout(function () { + callback(error); + }); + }); + } else { + callback(); + } +} + +function returnNull() { + return null; +} + +function computeHandlerProps(matches, query) { + var handler = returnNull; + var props = { + ref: null, + params: null, + query: null, + activeRouteHandler: handler, + key: null + }; + + reversedArray(matches).forEach(function (match) { + var route = match.route; + + props = Route.getUnreservedProps(route.props); + + props.ref = '__activeRoute__'; + props.params = match.params; + props.query = query; + props.activeRouteHandler = handler; + + // TODO: Can we remove addHandlerKey? + if (route.props.addHandlerKey) + props.key = Path.injectParams(route.props.path, match.params); + + handler = function (props, addedProps) { + if (arguments.length > 2 && typeof arguments[2] !== 'undefined') + throw new Error('Passing children to a route handler is not supported'); + + return route.props.handler( + copyProperties(props, addedProps) + ); + }.bind(this, props); + }); + + return props; +} + +var BrowserTransitionHandling = { + + handleTransition: function (transition) { + LocationDispatcher.handleViewAction({ + type: LocationActions.FINISHED_TRANSITION, + path: transition.path + }); + }, + + handleTransitionError: function (error) { + throw error; // This error probably originated in a transition hook. + }, + + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + this.replaceWith(reason.to, reason.params, reason.query); + } else { + this.goBack(); + } + } + +}; + +var ServerTransitionHandling = { + + handleTransition: function (transition) { + // TODO + }, + + handleTransitionError: function (error) { + // TODO + }, + + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + // TODO + } else { + // TODO + } + } + +}; + +var TransitionHandling = canUseDOM ? BrowserTransitionHandling : ServerTransitionHandling; + +var ActiveContext = require('../mixins/ActiveContext'); +var LocationContext = require('../mixins/LocationContext'); +var RouteContext = require('../mixins/RouteContext'); +var ScrollContext = require('../mixins/ScrollContext'); /** * The component configures the route hierarchy and renders the @@ -11,7 +327,140 @@ var Routes = React.createClass({ displayName: 'Routes', - mixins: [ TransitionHandler ], + mixins: [ ActiveContext, LocationContext, RouteContext, ScrollContext ], + + propTypes: { + onTransition: React.PropTypes.func.isRequired, + onTransitionError: React.PropTypes.func.isRequired, + onAbortedTransition: React.PropTypes.func.isRequired, + initialPath: React.PropTypes.string + }, + + getDefaultProps: function () { + return { + onTransition: TransitionHandling.handleTransition, + onTransitionError: TransitionHandling.handleTransitionError, + onAbortedTransition: TransitionHandling.handleAbortedTransition + }; + }, + + getInitialState: function () { + return { + matches: [] + }; + }, + + componentWillMount: function () { + this.handlePathChange(this.props.initialPath); + }, + + componentDidMount: function () { + PathStore.addChangeListener(this.handlePathChange); + ScrollStore.addChangeListener(this.handleScrollChange); + }, + + componentWillUnmount: function () { + ScrollStore.removeChangeListener(this.handleScrollChange); + PathStore.removeChangeListener(this.handlePathChange); + }, + + handlePathChange: function (_path) { + var path = _path || PathStore.getCurrentPath(); + + if (this.state.path === path) + return; // Nothing to do! + + var self = this; + + this.dispatch(path, function (error, transition) { + if (error) { + self.props.onTransitionError.call(self, error); + } else if (transition.isAborted) { + self.props.onAbortedTransition.call(self, transition); + } else { + self.props.onTransition.call(self, transition); + } + }); + }, + + handleScrollChange: function () { + var behavior = this.getScrollBehavior(); + var position = ScrollStore.getCurrentScrollPosition(); + + if (behavior && position) + behavior.updateScrollPosition(position, PathStore.getCurrentActionType()); + }, + + /** + * Performs a depth-first search for the first route in the tree that matches on + * the given path. Returns an array of all routes in the tree leading to the one + * that matched in the format { route, params } where params is an object that + * contains the URL parameters relevant to that route. Returns null if no route + * in the tree matches the path. + * + * React.renderComponent( + * + * + * + * + * + * + * ).match('/posts/123'); => [ { route: , params: {} }, + * { route: , params: { id: '123' } } ] + */ + match: function (path) { + return findMatches(Path.withoutQuery(path), this.getRoutes(), this.props.defaultRoute, this.props.notFoundRoute); + }, + + /** + * Performs a transition to the given path and calls callback(error, transition) + * with the Transition object when the transition is finished and the component's + * state has been updated accordingly. + * + * In a transition, the router first determines which routes are involved by + * beginning with the current route, up the route tree to the first parent route + * that is shared with the destination route, and back down the tree to the + * destination route. The willTransitionFrom hook is invoked on all route handlers + * we're transitioning away from, in reverse nesting order. Likewise, the + * willTransitionTo hook is invoked on all route handlers we're transitioning to. + * + * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect + * the transition. To resolve asynchronously, they may use transition.wait(promise). + */ + dispatch: function (path, callback) { + var transition = new Transition(this, path); + var self = this; + + computeNextState(this, transition, function (error, nextState) { + if (error || nextState == null) + return callback(error, transition); + + self.setState(nextState, function () { + callback(null, transition); + }); + }); + }, + + /** + * Returns the props that should be used for the top-level route handler. + */ + getHandlerProps: function () { + return computeHandlerProps(this.state.matches, this.state.activeQuery); + }, + + /** + * Returns the current URL path. + */ + getCurrentPath: function () { + return this.state.path; + }, + + /** + * Returns a reference to the active route handler's component instance. + */ + getActiveComponent: function () { + return this.refs.__activeRoute__; + }, render: function () { var match = this.state.matches[0]; @@ -22,6 +471,16 @@ var Routes = React.createClass({ return match.route.props.handler( this.getHandlerProps() ); + }, + + childContextTypes: { + currentPath: React.PropTypes.string + }, + + getChildContext: function () { + return { + currentPath: this.getCurrentPath() + }; } }); diff --git a/modules/components/__tests__/DefaultRoute-test.js b/modules/components/__tests__/DefaultRoute-test.js index a5bb6ffd9e..5702cf8845 100644 --- a/modules/components/__tests__/DefaultRoute-test.js +++ b/modules/components/__tests__/DefaultRoute-test.js @@ -2,35 +2,28 @@ var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; -var RouteContainer = require('../../mixins/RouteContainer'); -var TransitionHandler = require('../../mixins/TransitionHandler'); -var PathStore = require('../../stores/PathStore'); -var Route = require('../Route'); var DefaultRoute = require('../DefaultRoute'); +var Routes = require('../Routes'); +var Route = require('../Route'); -afterEach(function () { - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); +var NullHandler = React.createClass({ + render: function () { + return null; + } }); describe('A DefaultRoute', function () { + it('has a null path', function () { expect(DefaultRoute({ path: '/' }).props.path).toBe(null); }); - var App = React.createClass({ - mixins: [ RouteContainer ], - render: function () { - return React.DOM.div(); - } - }); - describe('at the root of a container', function () { var component, route; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, - route = DefaultRoute({ handler: App }) + Routes({ location: 'none' }, + route = DefaultRoute({ handler: NullHandler }) ) ); }); @@ -48,9 +41,9 @@ describe('A DefaultRoute', function () { var component, route, defaultRoute; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, - route = Route({ handler: App }, - defaultRoute = DefaultRoute({ handler: App }) + Routes({ location: 'none' }, + route = Route({ handler: NullHandler }, + defaultRoute = DefaultRoute({ handler: NullHandler }) ) ) ); @@ -64,25 +57,20 @@ describe('A DefaultRoute', function () { expect(route.props.defaultRoute).toBe(defaultRoute); }); }); + }); describe('when no child routes match a URL, but the parent\'s path matches', function () { - var App = React.createClass({ - mixins: [ TransitionHandler ], - render: function () { - return React.DOM.div(); - } - }); var component, rootRoute, defaultRoute; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App({ location: 'none' }, - rootRoute = Route({ name: 'user', path: '/users/:id', handler: App }, - Route({ name: 'home', path: '/users/:id/home', handler: App }), + Routes({ location: 'none' }, + rootRoute = Route({ name: 'user', path: '/users/:id', handler: NullHandler }, + Route({ name: 'home', path: '/users/:id/home', handler: NullHandler }), // Make it the middle sibling to test order independence. - defaultRoute = DefaultRoute({ handler: App }), - Route({ name: 'news', path: '/users/:id/news', handler: App }) + defaultRoute = DefaultRoute({ handler: NullHandler }), + Route({ name: 'news', path: '/users/:id/news', handler: NullHandler }) ) ) ) @@ -99,4 +87,5 @@ describe('when no child routes match a URL, but the parent\'s path matches', fun expect(matches[0].route).toBe(rootRoute); expect(matches[1].route).toBe(defaultRoute); }); + }); diff --git a/modules/components/__tests__/Link-test.js b/modules/components/__tests__/Link-test.js index a83c636d54..850a0c50eb 100644 --- a/modules/components/__tests__/Link-test.js +++ b/modules/components/__tests__/Link-test.js @@ -2,19 +2,41 @@ var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; -var PathStore = require('../../stores/PathStore'); var DefaultRoute = require('../DefaultRoute'); var Routes = require('../Routes'); +var Route = require('../Route'); var Link = require('../Link'); -afterEach(function () { - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); -}); - describe('A Link', function () { + + describe('with params and a query', function () { + var HomeHandler = React.createClass({ + render: function () { + return Link({ ref: 'link', to: 'home', params: { username: 'mjackson' }, query: { awesome: true } }); + } + }); + + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + Routes({ location: 'history', initialPath: '/mjackson/home' }, + Route({ name: 'home', path: '/:username/home', handler: HomeHandler }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('knows how to make its href', function () { + var linkComponent = component.getActiveComponent().refs.link; + expect(linkComponent.getHref()).toEqual('/mjackson/home?awesome=true'); + }); + }); + describe('when its route is active', function () { - var Home = React.createClass({ + var HomeHandler = React.createClass({ render: function () { return Link({ ref: 'link', to: 'home', className: 'a-link', activeClassName: 'highlight' }); } @@ -24,7 +46,7 @@ describe('A Link', function () { beforeEach(function () { component = ReactTestUtils.renderIntoDocument( Routes(null, - DefaultRoute({ name: 'home', handler: Home }) + DefaultRoute({ name: 'home', handler: HomeHandler }) ) ); }); @@ -33,14 +55,10 @@ describe('A Link', function () { React.unmountComponentAtNode(component.getDOMNode()); }); - it('is active', function () { - var linkComponent = component.getActiveRoute().refs.link; - assert(linkComponent.isActive); - }); - it('has its active class name', function () { - var linkComponent = component.getActiveRoute().refs.link; + var linkComponent = component.getActiveComponent().refs.link; expect(linkComponent.getClassName()).toEqual('a-link highlight'); }); }); + }); diff --git a/modules/components/__tests__/NotFoundRoute-test.js b/modules/components/__tests__/NotFoundRoute-test.js index a75e7f8d96..709d50c788 100644 --- a/modules/components/__tests__/NotFoundRoute-test.js +++ b/modules/components/__tests__/NotFoundRoute-test.js @@ -2,35 +2,28 @@ var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; -var RouteContainer = require('../../mixins/RouteContainer'); -var TransitionHandler = require('../../mixins/TransitionHandler'); -var PathStore = require('../../stores/PathStore'); -var Route = require('../Route'); var NotFoundRoute = require('../NotFoundRoute'); +var Routes = require('../Routes'); +var Route = require('../Route'); -afterEach(function () { - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); +var NullHandler = React.createClass({ + render: function () { + return null; + } }); describe('A NotFoundRoute', function () { + it('has a null path', function () { expect(NotFoundRoute({ path: '/' }).props.path).toBe(null); }); - var App = React.createClass({ - mixins: [ RouteContainer ], - render: function () { - return React.DOM.div(); - } - }); - describe('at the root of a container', function () { var component, route; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, - route = NotFoundRoute({ handler: App }) + Routes({ location: 'none' }, + route = NotFoundRoute({ handler: NullHandler }) ) ); }); @@ -48,9 +41,9 @@ describe('A NotFoundRoute', function () { var component, route, notFoundRoute; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, - route = Route({ handler: App }, - notFoundRoute = NotFoundRoute({ handler: App }) + Routes({ location: 'none' }, + route = Route({ handler: NullHandler }, + notFoundRoute = NotFoundRoute({ handler: NullHandler }) ) ) ); @@ -64,25 +57,20 @@ describe('A NotFoundRoute', function () { expect(route.props.notFoundRoute).toBe(notFoundRoute); }); }); + }); describe('when no child routes match a URL, but the beginning of the parent\'s path matches', function () { - var App = React.createClass({ - mixins: [ TransitionHandler ], - render: function () { - return React.DOM.div(); - } - }); var component, rootRoute, notFoundRoute; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App({ location: 'none' }, - rootRoute = Route({ name: 'user', path: '/users/:id', handler: App }, - Route({ name: 'home', path: '/users/:id/home', handler: App }), + Routes({ location: 'none' }, + rootRoute = Route({ name: 'user', path: '/users/:id', handler: NullHandler }, + Route({ name: 'home', path: '/users/:id/home', handler: NullHandler }), // Make it the middle sibling to test order independence. - notFoundRoute = NotFoundRoute({ handler: App }), - Route({ name: 'news', path: '/users/:id/news', handler: App }) + notFoundRoute = NotFoundRoute({ handler: NullHandler }), + Route({ name: 'news', path: '/users/:id/news', handler: NullHandler }) ) ) ) @@ -99,4 +87,5 @@ describe('when no child routes match a URL, but the beginning of the parent\'s p expect(matches[0].route).toBe(rootRoute); expect(matches[1].route).toBe(notFoundRoute); }); + }); diff --git a/modules/components/__tests__/Routes-test.js b/modules/components/__tests__/Routes-test.js index 28db6bf4be..59e19ad567 100644 --- a/modules/components/__tests__/Routes-test.js +++ b/modules/components/__tests__/Routes-test.js @@ -2,7 +2,6 @@ var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; -var PathStore = require('../../stores/PathStore'); var Routes = require('../Routes'); var Route = require('../Route'); @@ -10,26 +9,21 @@ function getRootMatch(matches) { return matches[matches.length - 1]; } -afterEach(function () { - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); +var NullHandler = React.createClass({ + render: function () { + return null; + } }); describe('A Routes', function () { - var App = React.createClass({ - render: function () { - return null; - } - }); - describe('that matches a URL', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( Routes(null, - Route({ handler: App }, - Route({ path: '/a/b/c', handler: App }) + Route({ handler: NullHandler }, + Route({ path: '/a/b/c', handler: NullHandler }) ) ) ); @@ -54,8 +48,8 @@ describe('A Routes', function () { beforeEach(function () { component = ReactTestUtils.renderIntoDocument( Routes(null, - Route({ handler: App }, - Route({ path: '/posts/:id/edit', handler: App }) + Route({ handler: NullHandler }, + Route({ path: '/posts/:id/edit', handler: NullHandler }) ) ) ); @@ -78,14 +72,14 @@ describe('A Routes', function () { describe('when a transition is aborted', function () { it('triggers onAbortedTransition', function (done) { - var App = React.createClass({ + var AbortHandler = React.createClass({ statics: { willTransitionTo: function (transition) { transition.abort(); } }, render: function () { - return React.DOM.div(); + return null; } }); @@ -96,7 +90,7 @@ describe('A Routes', function () { ReactTestUtils.renderIntoDocument( Routes({ onAbortedTransition: handleAbortedTransition }, - Route({ handler: App }) + Route({ handler: AbortHandler }) ) ); }); @@ -104,14 +98,14 @@ describe('A Routes', function () { describe('when there is an error in a transition hook', function () { it('triggers onTransitionError', function (done) { - var App = React.createClass({ + var ErrorHandler = React.createClass({ statics: { willTransitionTo: function (transition) { throw new Error('boom!'); } }, render: function () { - return React.DOM.div(); + return null; } }); @@ -123,7 +117,7 @@ describe('A Routes', function () { ReactTestUtils.renderIntoDocument( Routes({ onTransitionError: handleTransitionError }, - Route({ handler: App }) + Route({ handler: ErrorHandler }) ) ); }); diff --git a/modules/index.js b/modules/index.js index 81813a5864..e11a2565f5 100644 --- a/modules/index.js +++ b/modules/index.js @@ -6,7 +6,6 @@ exports.Route = require('./components/Route'); exports.Routes = require('./components/Routes'); exports.ActiveState = require('./mixins/ActiveState'); -exports.AsyncState = require('./mixins/AsyncState'); -exports.PathState = require('./mixins/PathState'); -exports.RouteLookup = require('./mixins/RouteLookup'); -exports.Transitions = require('./mixins/Transitions'); +exports.AsyncState = require('./mixins/AsyncState'); // TODO: Remove. +exports.CurrentPath = require('./mixins/CurrentPath'); +exports.Navigation = require('./mixins/Navigation'); diff --git a/modules/locations/HashLocation.js b/modules/locations/HashLocation.js index 6e3876fa91..a783f58c44 100644 --- a/modules/locations/HashLocation.js +++ b/modules/locations/HashLocation.js @@ -28,7 +28,8 @@ function onHashChange() { LocationDispatcher.handleViewAction({ // If we don't have an _actionType then all we know is the hash // changed. It was probably caused by the user clicking the Back - // button, but may have also been the Forward button. + // button, but may have also been the Forward button or manual + // manipulation. So just guess 'pop'. type: _actionType || LocationActions.POP, path: getHashPath() }); diff --git a/modules/mixins/ActiveDelegate.js b/modules/mixins/ActiveContext.js similarity index 62% rename from modules/mixins/ActiveDelegate.js rename to modules/mixins/ActiveContext.js index dd94bf5a0d..76c6cd035e 100644 --- a/modules/mixins/ActiveDelegate.js +++ b/modules/mixins/ActiveContext.js @@ -1,5 +1,4 @@ var React = require('react'); -var ChangeEmitter = require('./ChangeEmitter'); function routeIsActive(activeRoutes, routeName) { return activeRoutes.some(function (route) { @@ -27,22 +26,10 @@ function queryIsActive(activeQuery, query) { * A mixin for components that store the active state of routes, URL * parameters, and query. */ -var ActiveDelegate = { - - mixins: [ ChangeEmitter ], - - childContextTypes: { - activeDelegate: React.PropTypes.any.isRequired - }, - - getChildContext: function () { - return { - activeDelegate: this - }; - }, +var ActiveContext = { propTypes: { - initialActiveState: React.PropTypes.object // Mainly for testing. + initialActiveState: React.PropTypes.object }, getDefaultProps: function () { @@ -61,6 +48,27 @@ var ActiveDelegate = { }; }, + /** + * Returns an array of the currently active routes. + */ + getActiveRoutes: function () { + return this.state.activeRoutes; + }, + + /** + * Returns an object of the currently active URL parameters. + */ + getActiveParams: function () { + return this.state.activeParams; + }, + + /** + * Returns an object of the currently active query parameters. + */ + getActiveQuery: function () { + return this.state.activeQuery; + }, + /** * Returns true if the route with the given name, URL parameters, and * query are all currently active. @@ -73,8 +81,24 @@ var ActiveDelegate = { return isActive && queryIsActive(this.state.activeQuery, query); return isActive; + }, + + childContextTypes: { + activeRoutes: React.PropTypes.array.isRequired, + activeParams: React.PropTypes.object.isRequired, + activeQuery: React.PropTypes.object.isRequired, + isActive: React.PropTypes.func.isRequired + }, + + getChildContext: function () { + return { + activeRoutes: this.getActiveRoutes(), + activeParams: this.getActiveParams(), + activeQuery: this.getActiveQuery(), + isActive: this.isActive + }; } }; -module.exports = ActiveDelegate; +module.exports = ActiveContext; diff --git a/modules/mixins/ActiveState.js b/modules/mixins/ActiveState.js index a4445561d0..7d30c458a4 100644 --- a/modules/mixins/ActiveState.js +++ b/modules/mixins/ActiveState.js @@ -1,66 +1,45 @@ var React = require('react'); -var ActiveDelegate = require('./ActiveDelegate'); /** - * A mixin for components that need to know about the routes, params, - * and query that are currently active. Components that use it get two - * things: - * - * 1. An `updateActiveState` method that is called when the - * active state changes. - * 2. An `isActive` method they can use to check if a route, - * params, and query are active. - * - * Example: - * - * var Tab = React.createClass({ - * - * mixins: [ Router.ActiveState ], - * - * getInitialState: function () { - * return { - * isActive: false - * }; - * }, - * - * updateActiveState: function () { - * this.setState({ - * isActive: this.isActive(routeName, params, query) - * }) - * } - * - * }); + * A mixin for components that need to know the routes, URL + * params and query that are currently active. */ var ActiveState = { contextTypes: { - activeDelegate: React.PropTypes.any.isRequired + activeRoutes: React.PropTypes.array.isRequired, + activeParams: React.PropTypes.object.isRequired, + activeQuery: React.PropTypes.object.isRequired, + isActive: React.PropTypes.func.isRequired }, - componentWillMount: function () { - if (this.updateActiveState) - this.updateActiveState(); - }, - - componentDidMount: function () { - this.context.activeDelegate.addChangeListener(this.handleActiveStateChange); + /** + * Returns an array of the routes that are currently active. + */ + getActiveRoutes: function () { + return this.context.activeRoutes; }, - componentWillUnmount: function () { - this.context.activeDelegate.removeChangeListener(this.handleActiveStateChange); + /** + * Returns an object of the URL params that are currently active. + */ + getActiveParams: function () { + return this.context.activeParams; }, - handleActiveStateChange: function () { - if (this.isMounted() && this.updateActiveState) - this.updateActiveState(); + /** + * Returns an object of the query params that are currently active. + */ + getActiveQuery: function () { + return this.context.activeQuery; }, /** - * Returns true if the route with the given name, URL parameters, and - * query are all currently active. + * A helper method to determine if a given route, params, and query + * are active. */ - isActive: function (routeName, params, query) { - return this.context.activeDelegate.isActive(routeName, params, query); + isActive: function (to, params, query) { + return this.context.isActive(to, params, query); } }; diff --git a/modules/mixins/ChangeEmitter.js b/modules/mixins/ChangeEmitter.js deleted file mode 100644 index 106ed9f3b9..0000000000 --- a/modules/mixins/ChangeEmitter.js +++ /dev/null @@ -1,50 +0,0 @@ -var React = require('react'); -var EventEmitter = require('events').EventEmitter; - -var CHANGE_EVENT = 'change'; - -/** - * A mixin for components that emit change events. ActiveDelegate uses - * this mixin to notify descendant ActiveState components when the - * active state changes. - */ -var ChangeEmitter = { - - propTypes: { - maxChangeListeners: React.PropTypes.number.isRequired - }, - - getDefaultProps: function () { - return { - maxChangeListeners: 0 - }; - }, - - componentWillMount: function () { - this._events = new EventEmitter; - this._events.setMaxListeners(this.props.maxChangeListeners); - }, - - componentWillReceiveProps: function (nextProps) { - this._events.setMaxListeners(nextProps.maxChangeListeners); - }, - - componentWillUnmount: function () { - this._events.removeAllListeners(); - }, - - addChangeListener: function (listener) { - this._events.addListener(CHANGE_EVENT, listener); - }, - - removeChangeListener: function (listener) { - this._events.removeListener(CHANGE_EVENT, listener); - }, - - emitChange: function () { - this._events.emit(CHANGE_EVENT); - } - -}; - -module.exports = ChangeEmitter; diff --git a/modules/mixins/CurrentPath.js b/modules/mixins/CurrentPath.js new file mode 100644 index 0000000000..7cf546cdc3 --- /dev/null +++ b/modules/mixins/CurrentPath.js @@ -0,0 +1,32 @@ +var React = require('react'); + +/** + * A mixin for components that need to know the current URL path. + * + * Example: + * + * var ShowThePath = React.createClass({ + * mixins: [ Router.CurrentPath ], + * render: function () { + * return ( + *
The current path is: {this.getCurrentPath()}
+ * ); + * } + * }); + */ +var CurrentPath = { + + contextTypes: { + currentPath: React.PropTypes.string.isRequired + }, + + /** + * Returns the current URL path. + */ + getCurrentPath: function () { + return this.context.currentPath; + } + +}; + +module.exports = CurrentPath; diff --git a/modules/mixins/LocationContext.js b/modules/mixins/LocationContext.js new file mode 100644 index 0000000000..847f2bc281 --- /dev/null +++ b/modules/mixins/LocationContext.js @@ -0,0 +1,86 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var HashLocation = require('../locations/HashLocation'); +var HistoryLocation = require('../locations/HistoryLocation'); +var RefreshLocation = require('../locations/RefreshLocation'); +var supportsHistory = require('../utils/supportsHistory'); + +/** + * A hash of { name: location } pairs. + */ +var NAMED_LOCATIONS = { + none: null, + hash: HashLocation, + history: HistoryLocation, + refresh: RefreshLocation +}; + +/** + * A mixin for components that manage location. + */ +var LocationContext = { + + propTypes: { + location: function (props, propName, componentName) { + var location = props[propName]; + + if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) + return new Error('Unknown location "' + location + '", see ' + componentName); + } + }, + + getDefaultProps: function () { + return { + location: canUseDOM ? HashLocation : null + }; + }, + + getInitialState: function () { + var location = this.props.location; + + if (typeof location === 'string') + location = NAMED_LOCATIONS[location]; + + // Automatically fall back to full page refreshes in + // browsers that do not support HTML5 history. + if (location === HistoryLocation && !supportsHistory()) + location = RefreshLocation; + + return { + location: location + }; + }, + + componentWillMount: function () { + var location = this.getLocation(); + + invariant( + location == null || canUseDOM, + 'Cannot use location without a DOM' + ); + + if (location && location.setup) + location.setup(); + }, + + /** + * Returns the location object this component uses. + */ + getLocation: function () { + return this.state.location; + }, + + childContextTypes: { + location: React.PropTypes.object // Not required on the server. + }, + + getChildContext: function () { + return { + location: this.getLocation() + }; + } + +}; + +module.exports = LocationContext; diff --git a/modules/mixins/PathDelegate.js b/modules/mixins/Navigation.js similarity index 53% rename from modules/mixins/PathDelegate.js rename to modules/mixins/Navigation.js index e9c99b7795..269dd761aa 100644 --- a/modules/mixins/PathDelegate.js +++ b/modules/mixins/Navigation.js @@ -1,26 +1,16 @@ var React = require('react'); var invariant = require('react/lib/invariant'); -var PathState = require('./PathState'); -var RouteContainer = require('./RouteContainer'); -var LocationActions = require('../actions/LocationActions'); var HashLocation = require('../locations/HashLocation'); var Path = require('../utils/Path'); /** - * A mixin for components that manage the current URL path. + * A mixin for components that modify the URL. */ -var PathDelegate = { +var Navigation = { - mixins: [ PathState, RouteContainer ], - - childContextTypes: { - pathDelegate: React.PropTypes.any.isRequired - }, - - getChildContext: function () { - return { - pathDelegate: this - }; + contextTypes: { + location: React.PropTypes.object, // Not required on the server. + namedRoutes: React.PropTypes.object.isRequired }, /** @@ -32,7 +22,7 @@ var PathDelegate = { if (Path.isAbsolute(to)) { path = Path.normalize(to); } else { - var route = this.getRouteByName(to); + var route = this.context.namedRoutes[to]; invariant( route, @@ -53,7 +43,7 @@ var PathDelegate = { makeHref: function (to, params, query) { var path = this.makePath(to, params, query); - if (this.getLocation() === HashLocation) + if (this.context.location === HashLocation) return '#' + path; return path; @@ -64,16 +54,14 @@ var PathDelegate = { * a new URL onto the history stack. */ transitionTo: function (to, params, query) { - var path = this.makePath(to, params, query); - var location = this.getLocation(); - - // If we have a location, route the transition - // through it so the URL is updated as well. - if (location) { - location.push(path); - } else if (this.updatePath) { - this.updatePath(path, LocationActions.PUSH); - } + var location = this.context.location; + + invariant( + location, + 'You cannot use transitionTo without a location' + ); + + location.push(this.makePath(to, params, query)); }, /** @@ -81,27 +69,25 @@ var PathDelegate = { * the current URL in the history stack. */ replaceWith: function (to, params, query) { - var path = this.makePath(to, params, query); - var location = this.getLocation(); - - // If we have a location, route the transition - // through it so the URL is updated as well. - if (location) { - location.replace(path); - } else if (this.updatePath) { - this.updatePath(path, LocationActions.REPLACE); - } + var location = this.context.location; + + invariant( + location, + 'You cannot use replaceWith without a location' + ); + + location.replace(this.makePath(to, params, query)); }, /** * Transitions to the previous URL. */ goBack: function () { - var location = this.getLocation(); + var location = this.context.location; invariant( location, - 'You cannot goBack without a location' + 'You cannot use goBack without a location' ); location.pop(); @@ -109,4 +95,4 @@ var PathDelegate = { }; -module.exports = PathDelegate; +module.exports = Navigation; diff --git a/modules/mixins/PathState.js b/modules/mixins/PathState.js deleted file mode 100644 index 0535295f4c..0000000000 --- a/modules/mixins/PathState.js +++ /dev/null @@ -1,120 +0,0 @@ -var React = require('react'); -var invariant = require('react/lib/invariant'); -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; -var HashLocation = require('../locations/HashLocation'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); -var supportsHistory = require('../utils/supportsHistory'); -var PathStore = require('../stores/PathStore'); - -/** - * A hash of { name: location } pairs. - */ -var NAMED_LOCATIONS = { - none: null, - hash: HashLocation, - history: HistoryLocation, - refresh: RefreshLocation -}; - -/** - * A mixin for components that need to know the current URL path. Components - * that use it get two things: - * - * 1. An optional `location` prop that they use to track - * changes to the URL - * 2. An `updatePath` method that is called when the - * current URL path changes - * - * Example: - * - * var PathWatcher = React.createClass({ - * - * mixins: [ Router.PathState ], - * - * updatePath: function (path, actionType) { - * this.setState({ - * currentPath: path - * }); - * } - * - * }); - */ -var PathState = { - - propTypes: { - - fixedPath: React.PropTypes.string, - - location: function (props, propName, componentName) { - var location = props[propName]; - - if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) - return new Error('Unknown location "' + location + '", see ' + componentName); - } - - }, - - getDefaultProps: function () { - return { - fixedPath: null, - location: canUseDOM ? HashLocation : null - }; - }, - - /** - * Gets the location object this component uses to observe the URL. - */ - getLocation: function () { - var location = this.props.location; - - if (typeof location === 'string') - location = NAMED_LOCATIONS[location]; - - // Automatically fall back to full page refreshes in - // browsers that do not support HTML5 history. - if (location === HistoryLocation && !supportsHistory()) - location = RefreshLocation; - - return location; - }, - - componentWillMount: function () { - var location = this.getLocation(); - - invariant( - this.props.fixedPath == null || this.getLocation() == null, - 'You cannot use a fixed path with a location. Choose one or the other' - ); - - if (location && location.setup) - location.setup(); - - if (this.updatePath) - this.updatePath(this.getCurrentPath(), this.getCurrentActionType()); - }, - - componentDidMount: function () { - PathStore.addChangeListener(this.handlePathChange); - }, - - componentWillUnmount: function () { - PathStore.removeChangeListener(this.handlePathChange); - }, - - handlePathChange: function () { - if (this.isMounted() && this.updatePath) - this.updatePath(this.getCurrentPath(), this.getCurrentActionType()); - }, - - getCurrentPath: function () { - return this.props.fixedPath || PathStore.getCurrentPath(); - }, - - getCurrentActionType: function () { - return PathStore.getCurrentActionType(); - } - -}; - -module.exports = PathState; diff --git a/modules/mixins/RouteContainer.js b/modules/mixins/RouteContext.js similarity index 92% rename from modules/mixins/RouteContainer.js rename to modules/mixins/RouteContext.js index 6f2ee75638..34e869e1d2 100644 --- a/modules/mixins/RouteContainer.js +++ b/modules/mixins/RouteContext.js @@ -117,24 +117,14 @@ function processRoutes(children, container, namedRoutes) { /** * A mixin for components that have children. */ -var RouteContainer = { - - childContextTypes: { - routeContainer: React.PropTypes.any.isRequired - }, - - getChildContext: function () { - return { - routeContainer: this - }; - }, +var RouteContext = { getInitialState: function () { var namedRoutes = {}; return { - namedRoutes: namedRoutes, - routes: processRoutes(this.props.children, this, namedRoutes) + routes: processRoutes(this.props.children, this, namedRoutes), + namedRoutes: namedRoutes }; }, @@ -153,12 +143,24 @@ var RouteContainer = { }, /** - * Returns the with the given name, null if no such route exists. + * Returns the route with the given name. */ getRouteByName: function (routeName) { return this.state.namedRoutes[routeName] || null; + }, + + childContextTypes: { + routes: React.PropTypes.array.isRequired, + namedRoutes: React.PropTypes.object.isRequired + }, + + getChildContext: function () { + return { + routes: this.getRoutes(), + namedRoutes: this.getNamedRoutes(), + }; } }; -module.exports = RouteContainer; +module.exports = RouteContext; diff --git a/modules/mixins/RouteLookup.js b/modules/mixins/RouteLookup.js deleted file mode 100644 index 07a250fc92..0000000000 --- a/modules/mixins/RouteLookup.js +++ /dev/null @@ -1,36 +0,0 @@ -var React = require('react'); - -/** - * A mixin for components that need to lookup routes and/or - * build URL paths and links. - */ -var RouteLookup = { - - contextTypes: { - routeContainer: React.PropTypes.any.isRequired - }, - - /** - * See RouteContainer#getRoutes. - */ - getRoutes: function () { - return this.context.routeContainer.getRoutes(); - }, - - /** - * See RouteContainer#getNamedRoutes. - */ - getNamedRoutes: function () { - return this.context.routeContainer.getNamedRoutes(); - }, - - /** - * See RouteContainer#getRouteByName. - */ - getRouteByName: function (routeName) { - return this.context.routeContainer.getRouteByName(routeName); - } - -}; - -module.exports = RouteLookup; diff --git a/modules/mixins/ScrollContext.js b/modules/mixins/ScrollContext.js new file mode 100644 index 0000000000..0fe3a2e4f4 --- /dev/null +++ b/modules/mixins/ScrollContext.js @@ -0,0 +1,76 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var ImitateBrowserBehavior = require('../behaviors/ImitateBrowserBehavior'); +var ScrollToTopBehavior = require('../behaviors/ScrollToTopBehavior'); + +/** + * A hash of { name: scrollBehavior } pairs. + */ +var NAMED_SCROLL_BEHAVIORS = { + none: null, + browser: ImitateBrowserBehavior, + imitateBrowser: ImitateBrowserBehavior, + scrollToTop: ScrollToTopBehavior +}; + +/** + * A mixin for components that manage scroll position. + */ +var ScrollContext = { + + propTypes: { + scrollBehavior: function (props, propName, componentName) { + var behavior = props[propName]; + + if (typeof behavior === 'string' && !(behavior in NAMED_SCROLL_BEHAVIORS)) + return new Error('Unknown scroll behavior "' + behavior + '", see ' + componentName); + } + }, + + getDefaultProps: function () { + return { + scrollBehavior: canUseDOM ? ImitateBrowserBehavior : null + }; + }, + + getInitialState: function () { + var behavior = this.props.scrollBehavior; + + if (typeof behavior === 'string') + behavior = NAMED_SCROLL_BEHAVIORS[behavior]; + + return { + scrollBehavior: behavior + }; + }, + + componentWillMount: function () { + var behavior = this.getScrollBehavior(); + + invariant( + behavior == null || canUseDOM, + 'Cannot use scroll behavior without a DOM' + ); + }, + + /** + * Returns the scroll behavior object this component uses. + */ + getScrollBehavior: function () { + return this.state.scrollBehavior; + }, + + childContextTypes: { + scrollBehavior: React.PropTypes.object // Not required on the server. + }, + + getChildContext: function () { + return { + scrollBehavior: this.getScrollBehavior() + }; + } + +}; + +module.exports = ScrollContext; diff --git a/modules/mixins/ScrollDelegate.js b/modules/mixins/ScrollDelegate.js deleted file mode 100644 index 53444b8975..0000000000 --- a/modules/mixins/ScrollDelegate.js +++ /dev/null @@ -1,39 +0,0 @@ -var ScrollState = require('./ScrollState'); - -/** - * A mixin for components that manage the window's scroll position. - */ -var ScrollDelegate = { - - mixins: [ ScrollState ], - - componentWillMount: function () { - if (this.getScrollBehavior()) - this._scrollPositions = {}; - }, - - /** - * Records the current scroll position for the given path. - */ - recordScroll: function (path) { - if (this._scrollPositions) - this._scrollPositions[path] = this.getCurrentScrollPosition(); - }, - - /** - * Updates the current scroll position according to the last - * one that was recorded for the given path. - */ - updateScroll: function (path, actionType) { - if (this._scrollPositions) { - var behavior = this.getScrollBehavior(); - var position = this._scrollPositions[path]; - - if (behavior && position) - behavior.updateScrollPosition(position, actionType); - } - } - -}; - -module.exports = ScrollDelegate; diff --git a/modules/mixins/ScrollState.js b/modules/mixins/ScrollState.js deleted file mode 100644 index 4187e93def..0000000000 --- a/modules/mixins/ScrollState.js +++ /dev/null @@ -1,107 +0,0 @@ -var invariant = require('react/lib/invariant'); -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; -var LocationActions = require('../actions/LocationActions'); - -/** - * A scroll behavior that attempts to imitate the default behavior - * of modern browsers. - */ -var ImitateBrowserBehavior = { - - updateScrollPosition: function (position, actionType) { - switch (actionType) { - case LocationActions.PUSH: - case LocationActions.REPLACE: - window.scrollTo(0, 0); - break; - case LocationActions.POP: - window.scrollTo(position.x, position.y); - break; - } - } - -}; - -/** - * A scroll behavior that always scrolls to the top of the page - * after a transition. - */ -var ScrollToTopBehavior = { - - updateScrollPosition: function () { - window.scrollTo(0, 0); - } - -}; - -/** - * A hash of { name: scrollBehavior } pairs. - */ -var NAMED_SCROLL_BEHAVIORS = { - none: null, - imitateBrowser: ImitateBrowserBehavior, - scrollToTop: ScrollToTopBehavior -}; - -/** - * A mixin for components that need to know the current scroll position. - */ -var ScrollState = { - - propTypes: { - - scrollBehavior: function (props, propName, componentName) { - var behavior = props[propName]; - - if (typeof behavior === 'string' && !(behavior in NAMED_SCROLL_BEHAVIORS)) - return new Error('Unknown scroll behavior "' + behavior + '", see ' + componentName); - } - - }, - - getDefaultProps: function () { - return { - scrollBehavior: canUseDOM ? ImitateBrowserBehavior : null - }; - }, - - /** - * Gets the scroll behavior object this component uses to observe - * the current scroll position. - */ - getScrollBehavior: function () { - var behavior = this.props.scrollBehavior; - - if (typeof behavior === 'string') - behavior = NAMED_SCROLL_BEHAVIORS[behavior]; - - return behavior; - }, - - componentWillMount: function () { - var behavior = this.getScrollBehavior(); - - invariant( - behavior == null || canUseDOM, - 'Cannot use scroll behavior without a DOM' - ); - }, - - /** - * Returns the current scroll position as { x, y }. - */ - getCurrentScrollPosition: function () { - invariant( - canUseDOM, - 'Cannot get current scroll position without a DOM' - ); - - return { - x: window.scrollX, - y: window.scrollY - }; - } - -}; - -module.exports = ScrollState; diff --git a/modules/mixins/TransitionHandler.js b/modules/mixins/TransitionHandler.js deleted file mode 100644 index 288aff11e1..0000000000 --- a/modules/mixins/TransitionHandler.js +++ /dev/null @@ -1,425 +0,0 @@ -var React = require('react'); -var warning = require('react/lib/warning'); -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; -var copyProperties = require('react/lib/copyProperties'); -var Route = require('../components/Route'); -var ActiveDelegate = require('./ActiveDelegate'); -var PathDelegate = require('./PathDelegate'); -var ScrollDelegate = require('./ScrollDelegate'); -var reversedArray = require('../utils/reversedArray'); -var Transition = require('../utils/Transition'); -var Redirect = require('../utils/Redirect'); -var Path = require('../utils/Path'); - -function makeMatch(route, params) { - return { route: route, params: params }; -} - -function getRootMatch(matches) { - return matches[matches.length - 1]; -} - -function findMatches(path, routes, defaultRoute, notFoundRoute) { - var matches = null, route, params; - - for (var i = 0, len = routes.length; i < len; ++i) { - route = routes[i]; - - // Check the subtree first to find the most deeply-nested match. - matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); - - if (matches != null) { - var rootParams = getRootMatch(matches).params; - - params = route.props.paramNames.reduce(function (params, paramName) { - params[paramName] = rootParams[paramName]; - return params; - }, {}); - - matches.unshift(makeMatch(route, params)); - - return matches; - } - - // No routes in the subtree matched, so check this route. - params = Path.extractParams(route.props.path, path); - - if (params) - return [ makeMatch(route, params) ]; - } - - // No routes matched, so try the default route if there is one. - if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) - return [ makeMatch(defaultRoute, params) ]; - - // Last attempt: does the "not found" route match? - if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) - return [ makeMatch(notFoundRoute, params) ]; - - return matches; -} - -function hasMatch(matches, match) { - return matches.some(function (m) { - if (m.route !== match.route) - return false; - - for (var property in m.params) - if (m.params[property] !== match.params[property]) - return false; - - return true; - }); -} - -function updateMatchComponents(matches, refs) { - var i = 0, component; - while (component = refs.__activeRoute__) { - matches[i++].component = component; - refs = component.refs; - } -} - -/** - * Computes the next state for the given component and calls - * callback(error, nextState) when finished. Also runs all - * transition hooks along the way. - */ -function computeNextState(component, transition, callback) { - if (component.state.path === transition.path) - return callback(); // Nothing to do! - - var currentMatches = component.state.matches; - var nextMatches = component.match(transition.path); - - warning( - nextMatches, - 'No route matches path "' + transition.path + '". Make sure you have ' + - ' somewhere in your routes' - ); - - if (!nextMatches) - nextMatches = []; - - var fromMatches, toMatches; - if (currentMatches.length) { - updateMatchComponents(currentMatches, component.refs); - - fromMatches = currentMatches.filter(function (match) { - return !hasMatch(nextMatches, match); - }); - - toMatches = nextMatches.filter(function (match) { - return !hasMatch(currentMatches, match); - }); - } else { - fromMatches = []; - toMatches = nextMatches; - } - - var query = Path.extractQuery(transition.path) || {}; - - runTransitionFromHooks(fromMatches, transition, function (error) { - if (error || transition.isAborted) - return callback(error); - - runTransitionToHooks(toMatches, transition, query, function (error) { - if (error || transition.isAborted) - return callback(error); - - var matches = currentMatches.slice(0, currentMatches.length - fromMatches.length).concat(toMatches); - var rootMatch = getRootMatch(matches); - var params = (rootMatch && rootMatch.params) || {}; - var routes = matches.map(function (match) { - return match.route; - }); - - callback(null, { - path: transition.path, - matches: matches, - activeRoutes: routes, - activeParams: params, - activeQuery: query - }); - }); - }); -} - -/** - * Calls the willTransitionFrom hook of all handlers in the given matches - * serially in reverse with the transition object and the current instance of - * the route's handler, so that the deepest nested handlers are called first. - * Calls callback(error) when finished. - */ -function runTransitionFromHooks(matches, transition, callback) { - var hooks = reversedArray(matches).map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionFrom) - return handler.willTransitionFrom(transition, match.component); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Calls the willTransitionTo hook of all handlers in the given matches - * serially with the transition object and any params that apply to that - * handler. Calls callback(error) when finished. - */ -function runTransitionToHooks(matches, transition, query, callback) { - var hooks = matches.map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionTo) - handler.willTransitionTo(transition, match.params, query); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Runs all hook functions serially and calls callback(error) when finished. - * A hook may return a promise if it needs to execute asynchronously. - */ -function runHooks(hooks, callback) { - try { - var promise = hooks.reduce(function (promise, hook) { - // The first hook to use transition.wait makes the rest - // of the transition async from that point forward. - return promise ? promise.then(hook) : hook(); - }, null); - } catch (error) { - return callback(error); // Sync error. - } - - if (promise) { - // Use setTimeout to break the promise chain. - promise.then(function () { - setTimeout(callback); - }, function (error) { - setTimeout(function () { - callback(error); - }); - }); - } else { - callback(); - } -} - -function returnNull() { - return null; -} - -function computeHandlerProps(matches, query) { - var handler = returnNull; - var props = { - ref: null, - params: null, - query: null, - activeRouteHandler: handler, - key: null - }; - - reversedArray(matches).forEach(function (match) { - var route = match.route; - - props = Route.getUnreservedProps(route.props); - - props.ref = '__activeRoute__'; - props.params = match.params; - props.query = query; - props.activeRouteHandler = handler; - - // TODO: Can we remove addHandlerKey? - if (route.props.addHandlerKey) - props.key = Path.injectParams(route.props.path, match.params); - - handler = function (props, addedProps) { - if (arguments.length > 2 && typeof arguments[2] !== 'undefined') - throw new Error('Passing children to a route handler is not supported'); - - return route.props.handler( - copyProperties(props, addedProps) - ); - }.bind(this, props); - }); - - return props; -} - -var BrowserTransitionHandling = { - - /** - * Handles errors that were thrown asynchronously while transitioning. The - * default behavior is to re-throw the error so it isn't swallowed silently. - */ - handleTransitionError: function (error) { - throw error; // This error probably originated in a transition hook. - }, - - /** - * Handles aborted transitions. - */ - handleAbortedTransition: function (transition) { - var reason = transition.abortReason; - - if (reason instanceof Redirect) { - this.replaceWith(reason.to, reason.params, reason.query); - } else { - this.goBack(); - } - } - -}; - -var ServerTransitionHandling = { - - handleTransitionError: function (error) { - // TODO - }, - - handleAbortedTransition: function (transition) { - var reason = transition.abortReason; - - if (reason instanceof Redirect) { - // TODO - } else { - // TODO - } - } - -}; - -var TransitionHandling = canUseDOM ? BrowserTransitionHandling : ServerTransitionHandling; - -/** - * A mixin for components that handle transitions. - */ -var TransitionHandler = { - - mixins: [ ActiveDelegate, PathDelegate, ScrollDelegate ], - - propTypes: { - onTransitionError: React.PropTypes.func.isRequired, - onAbortedTransition: React.PropTypes.func.isRequired - }, - - getDefaultProps: function () { - return { - onTransitionError: TransitionHandling.handleTransitionError, - onAbortedTransition: TransitionHandling.handleAbortedTransition - }; - }, - - getInitialState: function () { - return { - matches: [] - }; - }, - - /** - * See PathState. - */ - updatePath: function (path, actionType) { - if (this.state.path === path) - return; // Nothing to do! - - if (this.state.path) - this.recordScroll(this.state.path); - - var self = this; - - this.dispatch(path, function (error, transition) { - if (error) { - self.props.onTransitionError.call(self, error); - } else if (transition.isAborted) { - self.props.onAbortedTransition.call(self, transition); - } else { - self.emitChange(); - self.updateScroll(path, actionType); - } - }); - }, - - /** - * Performs a depth-first search for the first route in the tree that matches on - * the given path. Returns an array of all routes in the tree leading to the one - * that matched in the format { route, params } where params is an object that - * contains the URL parameters relevant to that route. Returns null if no route - * in the tree matches the path. - * - * React.renderComponent( - * - * - * - * - * - * - * ).match('/posts/123'); => [ { route: , params: {} }, - * { route: , params: { id: '123' } } ] - */ - match: function (path) { - return findMatches(Path.withoutQuery(path), this.getRoutes(), this.props.defaultRoute, this.props.notFoundRoute); - }, - - /** - * Performs a transition to the given path and calls callback(error, transition) - * with the Transition object when the transition is finished and the component's - * state has been updated accordingly. - * - * In a transition, the router first determines which routes are involved by - * beginning with the current route, up the route tree to the first parent route - * that is shared with the destination route, and back down the tree to the - * destination route. The willTransitionFrom hook is invoked on all route handlers - * we're transitioning away from, in reverse nesting order. Likewise, the - * willTransitionTo hook is invoked on all route handlers we're transitioning to. - * - * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect - * the transition. To resolve asynchronously, they may use transition.wait(promise). - */ - dispatch: function (path, callback) { - var transition = new Transition(this, path); - var self = this; - - computeNextState(this, transition, function (error, nextState) { - if (error || nextState == null) - return callback(error, transition); - - self.setState(nextState, function () { - callback(null, transition); - }); - }); - }, - - /** - * Returns the props that should be used for the top-level route handler. - */ - getHandlerProps: function () { - return computeHandlerProps(this.state.matches, this.state.activeQuery); - }, - - /** - * Returns a reference to the active route handler's component instance. - */ - getActiveRoute: function () { - return this.refs.__activeRoute__; - } - -}; - -module.exports = TransitionHandler; diff --git a/modules/mixins/Transitions.js b/modules/mixins/Transitions.js deleted file mode 100644 index 6d0cf7467a..0000000000 --- a/modules/mixins/Transitions.js +++ /dev/null @@ -1,49 +0,0 @@ -var React = require('react'); - -/** - * A mixin for components that need to initiate transitions to other routes. - */ -var Transitions = { - - contextTypes: { - pathDelegate: React.PropTypes.any.isRequired - }, - - /** - * See PathDelegate#makePath. - */ - makePath: function (to, params, query) { - return this.context.pathDelegate.makePath(to, params, query); - }, - - /** - * See PathDelegate#makeHref. - */ - makeHref: function (to, params, query) { - return this.context.pathDelegate.makeHref(to, params, query); - }, - - /** - * See PathDelegate#transitionTo. - */ - transitionTo: function (to, params, query) { - return this.context.pathDelegate.transitionTo(to, params, query); - }, - - /** - * See PathDelegate#replaceWith. - */ - replaceWith: function (to, params, query) { - return this.context.pathDelegate.replaceWith(to, params, query); - }, - - /** - * See PathDelegate#goBack. - */ - goBack: function () { - return this.context.pathDelegate.goBack(); - } - -}; - -module.exports = Transitions; diff --git a/modules/mixins/__tests__/ActiveDelegate-test.js b/modules/mixins/__tests__/ActiveContext-test.js similarity index 94% rename from modules/mixins/__tests__/ActiveDelegate-test.js rename to modules/mixins/__tests__/ActiveContext-test.js index 7e49046569..62ea4ae0ab 100644 --- a/modules/mixins/__tests__/ActiveDelegate-test.js +++ b/modules/mixins/__tests__/ActiveContext-test.js @@ -2,13 +2,14 @@ var assert = require('assert'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; var Route = require('../../components/Route'); -var ActiveDelegate = require('../ActiveDelegate'); +var ActiveContext = require('../ActiveContext'); + +describe('ActiveContext', function () { -describe('ActiveDelegate', function () { var App = React.createClass({ - mixins: [ ActiveDelegate ], + mixins: [ ActiveContext ], render: function () { - return React.DOM.div(); + return null; } }); diff --git a/modules/mixins/__tests__/PathState-test.js b/modules/mixins/__tests__/LocationContext-test.js similarity index 78% rename from modules/mixins/__tests__/PathState-test.js rename to modules/mixins/__tests__/LocationContext-test.js index f4e6ab6924..2bc68f471c 100644 --- a/modules/mixins/__tests__/PathState-test.js +++ b/modules/mixins/__tests__/LocationContext-test.js @@ -3,17 +3,18 @@ var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; var HashLocation = require('../../locations/HashLocation'); var HistoryLocation = require('../../locations/HistoryLocation'); -var PathState = require('../PathState'); +var LocationContext = require('../LocationContext'); + +describe('LocationContext', function () { -describe('PathState', function () { var App = React.createClass({ - mixins: [ PathState ], + mixins: [ LocationContext ], render: function () { return React.DOM.div(); } }); - describe('when using location="none"', function () { + describe('when location="none"', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( @@ -30,7 +31,7 @@ describe('PathState', function () { }); }); - describe('when using location="hash"', function () { + describe('when location="hash"', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( @@ -42,12 +43,12 @@ describe('PathState', function () { React.unmountComponentAtNode(component.getDOMNode()); }); - it('has a null location', function () { + it('uses HashLocation', function () { expect(component.getLocation()).toBe(HashLocation); }); }); - describe('when using location="history"', function () { + describe('when location="history"', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( @@ -59,8 +60,9 @@ describe('PathState', function () { React.unmountComponentAtNode(component.getDOMNode()); }); - it('has a null location', function () { + it('uses HistoryLocation', function () { expect(component.getLocation()).toBe(HistoryLocation); }); }); + }); diff --git a/modules/mixins/__tests__/PathDelegate-test.js b/modules/mixins/__tests__/Navigation-test.js similarity index 55% rename from modules/mixins/__tests__/PathDelegate-test.js rename to modules/mixins/__tests__/Navigation-test.js index 0895643c08..428d38d421 100644 --- a/modules/mixins/__tests__/PathDelegate-test.js +++ b/modules/mixins/__tests__/Navigation-test.js @@ -1,15 +1,17 @@ +var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; +var Routes = require('../../components/Routes'); var Route = require('../../components/Route'); -var PathDelegate = require('../PathDelegate'); +var Navigation = require('../Navigation'); -describe('PathDelegate', function () { +describe('Navigation', function () { - var App = React.createClass({ - mixins: [ PathDelegate ], + var NavigationHandler = React.createClass({ + mixins: [ Navigation ], render: function () { - return React.DOM.div(); + return null; } }); @@ -18,8 +20,8 @@ describe('PathDelegate', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, - Route({ name: 'home', path: '/:username/home', handler: App }) + Routes({ initialPath: '/anybody/home' }, + Route({ name: 'home', path: '/:username/home', handler: NavigationHandler }) ) ); }); @@ -29,7 +31,9 @@ describe('PathDelegate', function () { }); it('creates the correct path', function () { - expect(component.makePath('home', { username: 'mjackson' })).toEqual('/mjackson/home'); + var activeComponent = component.getActiveComponent(); + assert(activeComponent); + expect(activeComponent.makePath('home', { username: 'mjackson' })).toEqual('/mjackson/home'); }); }); @@ -37,7 +41,9 @@ describe('PathDelegate', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App() + Routes({ initialPath: '/home' }, + Route({ name: 'home', handler: NavigationHandler }) + ) ); }); @@ -46,9 +52,12 @@ describe('PathDelegate', function () { }); it('creates the correct path', function () { + var activeComponent = component.getActiveComponent(); + assert(activeComponent); + expect(function () { - component.makePath('home'); - }).toThrow('Unable to find a route named "home". Make sure you have a defined somewhere in your '); + activeComponent.makePath('about'); + }).toThrow('Unable to find a route named "about". Make sure you have a defined somewhere in your '); }); }); }); @@ -58,8 +67,8 @@ describe('PathDelegate', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App({ location: 'hash' }, - Route({ name: 'home', handler: App }) + Routes({ location: 'hash', initialPath: '/home' }, + Route({ name: 'home', handler: NavigationHandler }) ) ); }); @@ -69,7 +78,9 @@ describe('PathDelegate', function () { }); it('puts a # in front of the URL', function () { - expect(component.makeHref('home')).toEqual('#/home'); + var activeComponent = component.getActiveComponent(); + assert(activeComponent); + expect(activeComponent.makeHref('home')).toEqual('#/home'); }); }); @@ -77,8 +88,8 @@ describe('PathDelegate', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App({ location: 'history' }, - Route({ name: 'home', handler: App }) + Routes({ location: 'history', initialPath: '/home' }, + Route({ name: 'home', handler: NavigationHandler }) ) ); }); @@ -88,7 +99,9 @@ describe('PathDelegate', function () { }); it('returns the correct URL', function () { - expect(component.makeHref('home')).toEqual('/home'); + var activeComponent = component.getActiveComponent(); + assert(activeComponent); + expect(activeComponent.makeHref('home')).toEqual('/home'); }); }); }); diff --git a/modules/mixins/__tests__/RouteContainer-test.js b/modules/mixins/__tests__/RouteContext-test.js similarity index 91% rename from modules/mixins/__tests__/RouteContainer-test.js rename to modules/mixins/__tests__/RouteContext-test.js index a8dd1e6884..d3a826cd43 100644 --- a/modules/mixins/__tests__/RouteContainer-test.js +++ b/modules/mixins/__tests__/RouteContext-test.js @@ -2,11 +2,12 @@ var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; var Route = require('../../components/Route'); -var RouteContainer = require('../RouteContainer'); +var RouteContext = require('../RouteContext'); + +describe('RouteContext', function () { -describe('RouteContainer', function () { var App = React.createClass({ - mixins: [ RouteContainer ], + mixins: [ RouteContext ], render: function () { return React.DOM.div(); } @@ -17,8 +18,10 @@ describe('RouteContainer', function () { var component, route; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, route = Route({ name: 'home', handler: App })) - ) + App(null, + route = Route({ name: 'home', handler: App }) + ) + ); }); afterEach(function () { @@ -34,8 +37,10 @@ describe('RouteContainer', function () { var component; beforeEach(function () { component = ReactTestUtils.renderIntoDocument( - App(null, Route({ name: 'home', handler: App })) - ) + App(null, + Route({ name: 'home', handler: App }) + ) + ); }); afterEach(function () { @@ -127,4 +132,5 @@ describe('RouteContainer', function () { expect(childRoute.props.path).toEqual('/sub'); }); }); + }); diff --git a/modules/mixins/__tests__/ScrollContext-test.js b/modules/mixins/__tests__/ScrollContext-test.js new file mode 100644 index 0000000000..84d8461f18 --- /dev/null +++ b/modules/mixins/__tests__/ScrollContext-test.js @@ -0,0 +1,85 @@ +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var ImitateBrowserBehavior = require('../../behaviors/ImitateBrowserBehavior'); +var ScrollToTopBehavior = require('../../behaviors/ScrollToTopBehavior'); +var ScrollContext = require('../ScrollContext'); + +describe('ScrollContext', function () { + + var App = React.createClass({ + mixins: [ ScrollContext ], + render: function () { + return React.DOM.div(); + } + }); + + describe('when scrollBehavior="none"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ scrollBehavior: 'none' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('has a null scroll behavior', function () { + expect(component.getScrollBehavior()).toBe(null); + }); + }); + + describe('when using scrollBehavior="browser"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ scrollBehavior: 'browser' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('uses ImitateBrowserBehavior', function () { + expect(component.getScrollBehavior()).toBe(ImitateBrowserBehavior); + }); + }); + + describe('when using scrollBehavior="imitateBrowser"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ scrollBehavior: 'imitateBrowser' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('uses ImitateBrowserBehavior', function () { + expect(component.getScrollBehavior()).toBe(ImitateBrowserBehavior); + }); + }); + + describe('when scrollBehavior="scrollToTop"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ scrollBehavior: 'scrollToTop' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('uses ScrollToTopBehavior', function () { + expect(component.getScrollBehavior()).toBe(ScrollToTopBehavior); + }); + }); + +}); diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index 5bce5baa4e..e423adc71b 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -1,6 +1,7 @@ var EventEmitter = require('events').EventEmitter; var LocationActions = require('../actions/LocationActions'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var ScrollStore = require('./ScrollStore'); var CHANGE_EVENT = 'change'; var _events = new EventEmitter; @@ -50,6 +51,8 @@ var PathStore = { case LocationActions.PUSH: case LocationActions.REPLACE: case LocationActions.POP: + LocationDispatcher.waitFor([ ScrollStore.dispatchToken ]); + if (_currentPath !== action.path) { _currentPath = action.path; _currentActionType = action.type; diff --git a/modules/stores/ScrollStore.js b/modules/stores/ScrollStore.js new file mode 100644 index 0000000000..7fcb0a2ce5 --- /dev/null +++ b/modules/stores/ScrollStore.js @@ -0,0 +1,79 @@ +var EventEmitter = require('events').EventEmitter; +var invariant = require('react/lib/invariant'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); +var PathStore; // TODO: Fix circular requires. + +var CHANGE_EVENT = 'change'; +var _events = new EventEmitter; + +function notifyChange() { + _events.emit(CHANGE_EVENT); +} + +function getCurrentScrollPosition() { + invariant( + canUseDOM, + 'Cannot get current scroll position without a DOM' + ); + + return { + x: window.scrollX, + y: window.scrollY + }; +} + +var _scrollPositions = {}, _currentScrollPosition; + +/** + * The ScrollStore keeps track of the current URL path. + */ +var ScrollStore = { + + addChangeListener: function (listener) { + _events.addListener(CHANGE_EVENT, listener); + }, + + removeChangeListener: function (listener) { + _events.removeListener(CHANGE_EVENT, listener); + }, + + removeAllChangeListeners: function () { + _events.removeAllListeners(CHANGE_EVENT); + }, + + /** + * Returns the last known scroll position for the current URL. + */ + getCurrentScrollPosition: function () { + return _currentScrollPosition; + }, + + dispatchToken: LocationDispatcher.register(function (payload) { + if (PathStore == null) + PathStore = require('./PathStore'); + + var action = payload.action; + + switch (action.type) { + case LocationActions.SETUP: + case LocationActions.PUSH: + case LocationActions.REPLACE: + case LocationActions.POP: + var currentPath = PathStore.getCurrentPath(); + + if (currentPath) + _scrollPositions[currentPath] = getCurrentScrollPosition(); + break; + + case LocationActions.FINISHED_TRANSITION: + _currentScrollPosition = _scrollPositions[action.path]; + notifyChange(); + break; + } + }) + +}; + +module.exports = ScrollStore; diff --git a/modules/stores/__tests__/PathStore-test.js b/modules/stores/__tests__/PathStore-test.js index 7871013da1..cc5dd13e9d 100644 --- a/modules/stores/__tests__/PathStore-test.js +++ b/modules/stores/__tests__/PathStore-test.js @@ -5,6 +5,7 @@ var LocationDispatcher = require('../../dispatchers/LocationDispatcher'); var PathStore = require('../PathStore'); describe('PathStore', function () { + beforeEach(function () { LocationDispatcher.handleViewAction({ type: LocationActions.PUSH, @@ -97,4 +98,5 @@ describe('PathStore', function () { assert(changeWasFired); }); }); + }); diff --git a/tests.js b/tests.js index 92b5d0826b..d62e764200 100644 --- a/tests.js +++ b/tests.js @@ -2,10 +2,24 @@ require('./modules/components/__tests__/DefaultRoute-test'); require('./modules/components/__tests__/Link-test'); require('./modules/components/__tests__/NotFoundRoute-test'); require('./modules/components/__tests__/Routes-test'); -require('./modules/mixins/__tests__/ActiveDelegate-test'); + +require('./modules/mixins/__tests__/ActiveContext-test'); require('./modules/mixins/__tests__/AsyncState-test'); -require('./modules/mixins/__tests__/PathDelegate-test'); -require('./modules/mixins/__tests__/PathState-test'); -require('./modules/mixins/__tests__/RouteContainer-test'); +require('./modules/mixins/__tests__/LocationContext-test'); +require('./modules/mixins/__tests__/Navigation-test'); +require('./modules/mixins/__tests__/RouteContext-test'); +require('./modules/mixins/__tests__/ScrollContext-test'); + require('./modules/stores/__tests__/PathStore-test'); + require('./modules/utils/__tests__/Path-test'); + + +var PathStore = require('./modules/stores/PathStore'); +var ScrollStore = require('./modules/stores/ScrollStore'); + +afterEach(function () { + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); + ScrollStore.removeAllChangeListeners(); +});