├── .coveralls.yml ├── .gitignore ├── .jshintignore ├── .npmignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── actions └── navigate.js ├── docs ├── navigateAction.md ├── navlink.md └── router-mixin.md ├── examples └── simple-flux.md ├── index.js ├── lib ├── History.js ├── NavLink.js └── RouterMixin.js ├── package.json ├── tests ├── mocks │ ├── MockAppComponent.js │ └── mockWindow.js └── unit │ ├── actions │ └── navigate.js │ ├── lib │ ├── History-test.js │ ├── NavLink-test.js │ └── RouterMixin-test.js │ └── utils │ └── HistoryWithHash-test.js └── utils ├── HistoryWithHash.js └── index.js /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /artifacts/ 2 | /build/ 3 | node_modules 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.jshintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /artifacts/ 2 | /examples/ 3 | /tests/ 4 | /build/ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | matrix: 4 | allow_failures: 5 | - node_js: "0.13" 6 | node_js: 7 | - "iojs" 8 | - "0.13" 9 | - "0.12" 10 | - "0.10" 11 | after_success: 12 | - "npm run cover" 13 | - "cat artifacts/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing Code to `flux-router-component` 2 | --------------------------------------------- 3 | 4 | Please be sure to sign our [CLA][] before you submit pull requests or otherwise contribute to `flux-router-component`. This protects developers, who rely on [BSD license][]. 5 | 6 | [BSD license]: https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md 7 | [CLA]: https://yahoocla.herokuapp.com/ 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Software License Agreement (BSD License) 2 | ======================================== 3 | 4 | Copyright (c) 2014, Yahoo! Inc. All rights reserved. 5 | ---------------------------------------------------- 6 | 7 | Redistribution and use of this software in source and binary forms, with or 8 | without modification, are permitted provided that the following conditions are 9 | met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | * Neither the name of Yahoo! Inc. nor the names of YUI's contributors may be 17 | used to endorse or promote products derived from this software without 18 | specific prior written permission of Yahoo! Inc. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 21 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 22 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 24 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 25 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 26 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 27 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 29 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flux-router-component 2 | 3 | ***Notice: This package is deprecated in favor of [`fluxible-router`](https://github.com/yahoo/fluxible-router).*** 4 | 5 | [![npm version](https://badge.fury.io/js/flux-router-component.svg)](http://badge.fury.io/js/flux-router-component) 6 | [![Build Status](https://travis-ci.org/yahoo/flux-router-component.svg?branch=master)](https://travis-ci.org/yahoo/flux-router-component) 7 | [![Dependency Status](https://david-dm.org/yahoo/flux-router-component.svg)](https://david-dm.org/yahoo/flux-router-component) 8 | [![devDependency Status](https://david-dm.org/yahoo/flux-router-component/dev-status.svg)](https://david-dm.org/yahoo/flux-router-component#info=devDependencies) 9 | [![Coverage Status](https://coveralls.io/repos/yahoo/flux-router-component/badge.png?branch=master)](https://coveralls.io/r/yahoo/flux-router-component?branch=master) 10 | 11 | Provides navigational React components (`NavLink`), router mixin (`RouterMixin`), and action `navigateAction` for applications built with [Flux](http://facebook.github.io/react/docs/flux-overview.html) architecture. Please check out [examples](https://github.com/yahoo/flux-router-component/tree/master/examples) of how to use these components. 12 | 13 | ## Context and Expected Context Methods 14 | 15 | Before we explain how to use `NavLink` and `RouterMixin`, lets start with two methods they expect: 16 | 17 | * `executeAction(navigateAction, payload)` - This executes navigate action, switches the app to the new route, and update the url. 18 | * `makePath(routeName, routeParams)` - This is used to generate url for a given route. 19 | 20 | These two methods need to be available in: 21 | 22 | * the React context of the component (access via `this.context` in the component), or 23 | * the `context` prop of the component (`this.props.context`) 24 | * If exists in both `this.context` and `this.props.context`, the one in `this.context` takes higher precedence. 25 | 26 | An example of such context is the `ComponentContext` provided by [fluxible-plugin-routr](https://github.com/yahoo/fluxible-plugin-routr/blob/master/lib/routr-plugin.js#L36), which is a plugin for [fluxible](https://github.com/yahoo/fluxible). We have a more sophisticated example application, [fluxible-router](https://github.com/yahoo/flux-examples/tree/master/fluxible-router), showing how everything works together. 27 | 28 | **Note** that React context is an undocumented feature, so its API could change without notice. Here is [a blog from Dave King](https://www.tildedave.com/2014/11/15/introduction-to-contexts-in-react-js.html) that explains what it is and how to use it. 29 | 30 | 31 | ## NavLink 32 | 33 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/navlink.md) 34 | 35 | ## RouterMixin 36 | 37 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/router-mixin.md) 38 | 39 | ## navigateAction 40 | 41 | [Docs](https://github.com/yahoo/flux-router-component/blob/master/docs/navigateAction.md) 42 | 43 | 44 | ## History Management (Browser Support and Hash-Based Routing) 45 | Considering different application needs and [different browser support levels for pushState](http://caniuse.com/#search=pushstate), this library provides the following options for browser history management: 46 | 47 | * Use `History` provided by this library (Default) 48 | * Use `HistoryWithHash` provided by this library 49 | * In addition, you can also customize it to use your own 50 | 51 | ### History 52 | This is the default `History` implementation `RouterMixin` uses. It is a straight-forward implementation that: 53 | * uses `pushState`/`replaceState` when they are available in the browser. 54 | * For the browsers without pushState support, `History` simply refreshes the page by setting `window.location.href = url` for `pushState`, and calling `window.location.replace(url)` for `replaceState`. 55 | 56 | ### HistoryWithHash 57 | Using hash-based url for client side routing has a lot of known issues. [History.js describes those issues pretty well](https://github.com/browserstate/history.js/wiki/Intelligent-State-Handling). 58 | 59 | But as always, there will be some applications out there that have to use it. This implementation provides a solution. 60 | 61 | If you do decide to use hash route, it is recommended to enable `checkRouteOnPageLoad`. Because hash fragment (that contains route) does not get sent to the server side, `RouterMixin` will compare the route info from server and route in the hash fragment. On route mismatch, it will dispatch a navigate action on browser side to load the actual page content for the route represented by the hash fragment. 62 | 63 | #### useHashRoute Config 64 | You can decide when to use hash-based routing through the `useHashRoute` option: 65 | 66 | * `useHashRoute=true` to force to use hash routing for all browsers, by setting `useHashRoute` to true when creating the `HistoryWithHash` instance; 67 | * `unspecified`, i.e. omitting the setting, to only use hash route for browsers without native pushState support; 68 | * `useHashRoute=false` to turn off hash routing for all browsers. 69 | 70 | | | useHashRoute = true | useHashRoute = false | useHashRoute unspecified | 71 | |--------------------------------------|-------------------------------------------------|---------------------------------------|--------------------------------| 72 | | Browsers *with* pushState support | history.pushState with /home#/path/to/pageB | history.pushState with /path/to/pageB | Same as `useHashRoute = false` | 73 | | Browsers *without* pushState support | page refresh to /home#/path/to/pageB | page refresh to /path/to/pageB | Same as `useHashRoute = true` | 74 | 75 | #### Custom Transformer for Hash Fragment 76 | By default, the hash fragments are just url paths. With `HistoryWithHash`, you can transform it to whatever syntax you need by passing `props.hashRouteTransformer` to the base React component that `RouterMixin` is mixed into. See the example below for how to configure it. 77 | 78 | #### Example 79 | This is an example of how you can use and configure `HistoryWithHash`: 80 | 81 | ```js 82 | var RouterMixin = require('flux-router-component').RouterMixin; 83 | var HistoryWithHash = require('flux-router-component/utils').HistoryWithHash; 84 | 85 | var Application = React.createClass({ 86 | mixins: [RouterMixin], 87 | ... 88 | }); 89 | 90 | var appComponent = Application({ 91 | ... 92 | historyCreator: function historyCreator() { 93 | return new HistoryWithHash({ 94 | // optional. Defaults to true if browser does not support pushState; false otherwise. 95 | useHashRoute: true, 96 | // optional. Defaults to '/'. Used when url has no hash fragment 97 | defaultHashRoute: '/default', 98 | // optional. Transformer for custom hash route syntax 99 | hashRouteTransformer: { 100 | transform: function (original) { 101 | // transform url hash fragment from '/new/path' to 'new-path' 102 | var transformed = original.replace('/', '-').replace(/^(\-+)/, ''); 103 | return transformed; 104 | }, 105 | reverse: function (transformed) { 106 | // reverse transform from 'new-path' to '/new/path' 107 | var original = '/' + (transformed && transformed.replace('-', '/')); 108 | return original; 109 | } 110 | } 111 | }); 112 | } 113 | }); 114 | 115 | ``` 116 | 117 | ### Provide Your Own History Manager 118 | If none of the history managers provided in this library works for your application, you can also customize the RouterMixin to use your own history manager implementation. Please follow the same API as `History`. 119 | 120 | #### API 121 | Please use `History.js` and `HistoryWithHash.js` as examples. 122 | 123 | * on(listener) 124 | * off(listener) 125 | * getUrl() 126 | * getState() 127 | * pushState(state, title, url) 128 | * replaceState(state, title, url) 129 | 130 | #### Example: 131 | 132 | ```js 133 | var RouterMixin = require('flux-router-component').RouterMixin; 134 | var MyHistory = require('MyHistoryManagerIsAwesome'); 135 | 136 | var Application = React.createClass({ 137 | mixins: [RouterMixin], 138 | ... 139 | }); 140 | 141 | var appComponent = Application({ 142 | ... 143 | historyCreator: function historyCreator() { 144 | return new MyHistory(); 145 | } 146 | }); 147 | 148 | ``` 149 | 150 | ## Scroll Position Management 151 | 152 | `RouterMixin` has a built-in mechanism for managing scroll position upon page navigation, for modern browsers that support native history state: 153 | 154 | * reset scroll position to `(0, 0)` when user clicks on a link and navigates to a new page, and 155 | * restore scroll position to last visited state when user clicks forward and back buttons to navigate between pages. 156 | 157 | If you want to disable this behavior, you can set `enableScroll` prop to `false` for `RouterMixin`. This is an example of how it can be done: 158 | 159 | ```js 160 | var RouterMixin = require('flux-router-component').RouterMixin; 161 | 162 | var Application = React.createClass({ 163 | mixins: [RouterMixin], 164 | ... 165 | }); 166 | 167 | var appComponent = Application({ 168 | ... 169 | enableScroll: false 170 | }); 171 | 172 | ``` 173 | 174 | ## onbeforeunload Support 175 | 176 | The `History` API does not allow `popstate` events to be cancelled, which results in `window.onbeforeunload()` methods not being triggered. This is problematic for users, since application state could be lost when they navigate to a certain page without knowing the consequences. 177 | 178 | Our solution is to check for a `window.onbeforeunload()` method, prompt the user with `window.confirm()`, and then navigate to the correct route based on the confirmation. If a route is cancelled by the user, we reset the page URL back to the original URL by using the `History` `pushState()` method. 179 | 180 | To implement the `window.onbeforeunload()` method, you need to set it within the components that need user verification before leaving a page. Here is an example: 181 | 182 | ```javascript 183 | componentDidMount: function() { 184 | window.onbeforeunload = function () { 185 | return 'Make sure to save your changes before leaving this page!'; 186 | } 187 | } 188 | ``` 189 | 190 | 191 | ## Polyfills 192 | `addEventListener` and `removeEventListener` polyfills are provided by: 193 | 194 | * Compatibility code example on [Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget.addEventListener) 195 | * A few DOM polyfill libaries listed on [Modernizer Polyfill wiki page](https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#dom). 196 | 197 | `Array.prototype.reduce` and `Array.prototype.map` (used by dependent library, query-string) polyfill examples are provided by: 198 | 199 | * [Mozilla Developer Network Array.prototype.reduce polyfill](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce#Polyfill) 200 | * [Mozilla Developer Network Array.prototype.map polyfill](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/map#Polyfill) 201 | 202 | You can also look into this [polyfill.io polyfill service](https://cdn.polyfill.io/v1/). 203 | 204 | ## Compatible React Versions 205 | 206 | | Compatible React Version | flux-router-component Version | 207 | |--------------------------|-------------------------------| 208 | | 0.12 | >= 0.4.1 | 209 | | 0.11 | < 0.4 | 210 | 211 | ## License 212 | This software is free to use under the Yahoo! Inc. BSD license. 213 | See the [LICENSE file][] for license text and copyright information. 214 | 215 | [LICENSE file]: https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md 216 | 217 | Third-pary open source code used are listed in our [package.json file]( https://github.com/yahoo/flux-router-component/blob/master/package.json). 218 | 219 | -------------------------------------------------------------------------------- /actions/navigate.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('navigateAction'); 2 | var queryString = require('query-string'); 3 | var searchPattern = /\?([^\#]*)/; 4 | 5 | function parseQueryString(url) { 6 | var search; 7 | var matches = url.match(searchPattern); 8 | if (matches) { 9 | search = matches[1]; 10 | } 11 | return (search && queryString.parse(search)) || {}; 12 | } 13 | 14 | module.exports = function (context, payload, done) { 15 | if (!context.router || !context.router.getRoute) { 16 | debug('no router available for navigate handling'); 17 | done(new Error('missing router')); 18 | return; 19 | } 20 | debug('executing', payload); 21 | 22 | var options = { 23 | navigate: payload, 24 | method: payload.method 25 | }; 26 | 27 | var route = context.router.getRoute(payload.url, options); 28 | 29 | if (!route) { 30 | var err = new Error('Url does not exist'); 31 | err.status = 404; 32 | done(err); 33 | return; 34 | } 35 | 36 | // add parsed query parameter object to route object, 37 | // and make it part of CHANGE_ROUTE_XXX action payload. 38 | route.query = parseQueryString(route.url); 39 | 40 | debug('dispatching CHANGE_ROUTE', route); 41 | context.dispatch('CHANGE_ROUTE_START', route); 42 | var action = route.config && route.config.action; 43 | 44 | if ('string' === typeof action && context.getAction) { 45 | action = context.getAction(action); 46 | } 47 | 48 | if (!action || 'function' !== typeof action) { 49 | debug('route has no action, dispatching without calling action'); 50 | context.dispatch('CHANGE_ROUTE_SUCCESS', route); 51 | done(); 52 | return; 53 | } 54 | 55 | debug('executing route action'); 56 | context.executeAction(action, route, function (err) { 57 | if (err) { 58 | context.dispatch('CHANGE_ROUTE_FAILURE', route); 59 | } else { 60 | context.dispatch('CHANGE_ROUTE_SUCCESS', route); 61 | } 62 | done(err); 63 | }); 64 | }; 65 | -------------------------------------------------------------------------------- /docs/navigateAction.md: -------------------------------------------------------------------------------- 1 | # navigateAction 2 | 3 | `navigateAction` is an action which will load a new route and update the URL of your application. You can call this action directly to update the URL of your application. It is also invoked indirectly when a user clicks on a [`NavLink`](./navlink.md). 4 | 5 | `navigateAction` expects a `{method, url}` object as a payload, where `method` is the HTTP method used to retrieve the URL (e.g. 'get'.) 6 | 7 | If no matching route is found, `navigateAction` will call the callback with an error where `err.status` is set to 404. 8 | 9 | If a route is successfully matched, `navigateAction` will first dispatch a `CHANGE_ROUTE_START` event, with route data as the payload (see below). `navigateAction` will then try to find an action associated with the route from the route config; this can either be an action function or the name of an action function (retrieved with `context.getAction(name)`.) If an action is found, it is executed, with route data as the payload. `navigateAction` finally will dispatch a `CHANGE_ROUTE_SUCCESS` event, or `CHANGE_ROUTE_FAILURE` event if the route's action returns an error. 10 | 11 | ## Route Data 12 | 13 | `navigateAction` passes a route data structure as the payload for all events and as the payload for any called actions. This consists of: 14 | 15 | | Field Name | Description | 16 | |------------|-----------------------------------------| 17 | | name | The name of the matched route. | 18 | | url | The actual URL that was matched. | 19 | | params | Parameters parsed from the route. | 20 | | query | Query parameters parsed from the URL. Note that if a query parameter occurs multiple times, the value in this hash will be an array. | 21 | | config | The configuation for the route. | 22 | | navigate | The payload passed to `navigateAction`. | 23 | -------------------------------------------------------------------------------- /docs/navlink.md: -------------------------------------------------------------------------------- 1 | # NavLink 2 | `NavLink` is the a React component for navigational links. When the link is clicked, NavLink will execute a [navigateAction]('./navigateAction.md'). Stores can register for `CHANGE_ROUTE_SUCCESS` handlers if they are interested 3 | in navigation events. 4 | 5 | | Prop Name | Prop Type | Description | 6 | |------------|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 7 | | href | String | The url string | 8 | | routeName | String | Not used if `href` is specified. This is the name of the target route, which should be defined in your app's routes. | 9 | | navParams | Object | If `href` prop is not available, `navParams` object will be used together with `routeName` to generate the href for the link. This object needs to contain route params the route path needs. Eg. for a route path `/article/:id`, `navParams.id` will be the article ID. | 10 | | followLink | boolean, default to false | If set to true, client side navigation will be disabled. NavLink will just act like a regular anchor link. | 11 | | replaceState | boolean, default to false | If set to true, replaceState is being used instead of pushState | 12 | | preserveScrollPosition | boolean, default to false | If set to true, the page will maintain its scroll position on route change. | 13 | 14 | 15 | ## Example Usage 16 | 17 | Here are two examples of generating `NavLink` using `href` property, and using `routeName` property. Using `href` property is better than using `routeName`, because: 18 | 19 | * Using `href` makes your code more readible, as it shows exactly how the `href` is generated. 20 | * Using `routeName` assumes `this.context` or `this.props.context` has a `makePath()` function, which will be used to generate the `href` from the `routeName` and `navParams` props. 21 | * Using `routeName` could be more limited, especially when it comes to query string and hash fragment, if the `makePath()` function does not support query string and hash fragment. 22 | 23 | ### Example of Using `href` Property (Recommended) 24 | 25 | If the url is static, or you can generate the url outside of `Navlink`, you can simply pass the url to `NavLink` as a prop. Here is an example: 26 | 27 | ```js 28 | var NavLink = require('flux-router-component').NavLink; 29 | 30 | var Nav = React.createClass({ 31 | render: function () { 32 | // This example is using this.props.context for Nav and NavLink components. 33 | // You can also use the React context, as described in the Context section of this doc. 34 | var pages, 35 | links, 36 | context = this.props.context; 37 | pages = [ 38 | { 39 | name: 'home', 40 | url: '/home', 41 | text: 'Home' 42 | }, 43 | { 44 | name: 'about', 45 | url: '/about', 46 | text: 'About Us' 47 | } 48 | ]; 49 | links = pages.map(function (page) { 50 | return ( 51 |
  • 52 | 53 | {page.text} 54 | 55 |
  • 56 | ); 57 | }); 58 | return ( 59 | 62 | ); 63 | 64 | } 65 | }); 66 | ``` 67 | 68 | ### Example of Using `routeName` Property 69 | 70 | Before you continue with this example, you should know that you can always generate the url yourself outside of `NavLink` and pass it to `NavLink` as `href` prop just like the example above. Your code will be more straight-forward that way, and you will have more control over how to generate `href` (see more explanations in [the Example Usage section](#example-usage)). 71 | 72 | If you choose not to generate `href` yourself and the `context` prop you pass to `NavLink` provides `makePath(routeName, routeParams)`, you can also use the `routeName` prop (and the optional `navParams` prop). If the `href` prop is not present, `NavLink` will use `makePath(this.props.routeName, this.props.navParams)` from either `this.context` or `this.props.context` to generate the `href` for the anchor element. The `navParams` prop is useful for dynamic routes. It should be a hash object containing the route parameters and their values. 73 | 74 | 75 | Here is a quick example code showcasing how to use `routeName` prop along with `navParams` prop: 76 | 77 | ```js 78 | // assume routes are defined somewhere like this: 79 | // var routes = { 80 | // home: { 81 | // path: '/', 82 | // page: 'home' 83 | // }, 84 | // article: { 85 | // path: '/article/:id', 86 | // page: 'article' 87 | // } 88 | // }; 89 | var pages = [ 90 | { 91 | routeName: 'home', 92 | text: 'Home' 93 | }, 94 | { 95 | routeName: 'article', 96 | routeParams: { 97 | id: 'a' 98 | } 99 | text: 'Article A' 100 | } 101 | ]; 102 | var Nav = React.createClass({ 103 | render: function () { 104 | // context should provide executeAction() and makePath(). 105 | // This example is using this.props.context for Nav and NavLink components. 106 | // You can also use the React context, as described in the Context section of this doc. 107 | var context = this.props.context; 108 | var links = pages.map(function (page) { 109 | return ( 110 |
  • 111 | 112 | {page.text} 113 | 114 |
  • 115 | ); 116 | }); 117 | return ( 118 | 121 | ); 122 | } 123 | }); 124 | ``` 125 | -------------------------------------------------------------------------------- /docs/router-mixin.md: -------------------------------------------------------------------------------- 1 | # RouterMixin 2 | `RouterMixin` is a React mixin to be used by application's top level React component to: 3 | 4 | * [manage browser history](#history-management-browser-support-and-hash-based-routing) when route changes, and 5 | * execute navigate action and then dispatch `CHANGE_ROUTE_START` and `CHANGE_ROUTE_SUCCESS` or `CHANGE_ROUTE_FAILURE` events via flux dispatcher on window `popstate` events 6 | * [manage scroll position](#scroll-position-management) when navigating between pages 7 | 8 | Note that the `RouterMixin` reads your component's `state.route`; your component is responsible for setting this value. Typically this would be done by setting up a store which listens for 'CHANGE_ROUTE_SUCCESS' events. 9 | 10 | ## Example Usage 11 | ```js 12 | // AppStateStore.js 13 | var createStore = require('fluxible/addons').createStore; 14 | module.exports = createStore({ 15 | storeName: 'AppStateStore', 16 | handlers: { 17 | 'CHANGE_ROUTE_SUCCESS': 'onNavigate' 18 | }, 19 | initialize: function() { 20 | this.route = null; 21 | }, 22 | dehydrate: function() { 23 | return this.route; 24 | }, 25 | rehydrate: function(state) { 26 | this.route = state; 27 | }, 28 | onNavigate: function(route) { 29 | this.route = route; 30 | return this.emitChange(); 31 | } 32 | }); 33 | ``` 34 | 35 | ```js 36 | // Application.jsx 37 | var RouterMixin = require('flux-router-component').RouterMixin; 38 | var AppStateStore = require('../stores/AppStateStore'); 39 | 40 | var Application = React.createClass({ 41 | mixins: [FluxibleMixin, RouterMixin], 42 | 43 | statics: { 44 | storeListeners: [AppStateStore] 45 | }, 46 | 47 | getInitialState: function() { 48 | return {route: this.getStore(AppStateStore).route}; 49 | }; 50 | 51 | onChange: function() { 52 | this.setState({ 53 | route: this.getStore(AppStateStore).route 54 | }); 55 | }; 56 | 57 | ... 58 | }); 59 | ``` 60 | -------------------------------------------------------------------------------- /examples/simple-flux.md: -------------------------------------------------------------------------------- 1 | [This flux-examples Github repo](https://github.com/yahoo/flux-examples) has a simple [Fluxible](http://www.fluxible.io/) application, [`fluxible-router`](https://github.com/yahoo/flux-examples/tree/master/fluxible-router), that works on both client and server. 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 'use strict'; 6 | 7 | module.exports = { 8 | NavLink: require('./lib/NavLink'), 9 | RouterMixin: require('./lib/RouterMixin'), 10 | navigateAction: require('./actions/navigate'), 11 | History: require('./lib/History') 12 | }; 13 | -------------------------------------------------------------------------------- /lib/History.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*global window */ 6 | 'use strict'; 7 | 8 | var EVENT_POPSTATE = 'popstate'; 9 | 10 | function isUndefined(v) { 11 | return v === undefined; 12 | } 13 | 14 | /** 15 | * This only supports pushState for the browsers with native pushState support. 16 | * For other browsers (mainly IE8 and IE9), it will refresh the page upon pushState() 17 | * and replaceState(). 18 | * @class History 19 | * @constructor 20 | * @param {Object} [options] The options object 21 | * @param {Window} [options.win=window] The window object 22 | */ 23 | function History(options) { 24 | this.win = (options && options.win) || window; 25 | this._hasPushState = !!(this.win && this.win.history && this.win.history.pushState); 26 | } 27 | 28 | History.prototype = { 29 | /** 30 | * Add the given listener for 'popstate' event (nothing happens for browsers that 31 | * don't support popstate event). 32 | * @method on 33 | * @param {Function} listener 34 | */ 35 | on: function (listener) { 36 | if (this._hasPushState) { 37 | this.win.addEventListener(EVENT_POPSTATE, listener); 38 | } 39 | }, 40 | 41 | /** 42 | * Remove the given listener for 'popstate' event (nothing happens for browsers that 43 | * don't support popstate event). 44 | * @method off 45 | * @param {Function} listener 46 | */ 47 | off: function (listener) { 48 | if (this._hasPushState) { 49 | this.win.removeEventListener(EVENT_POPSTATE, listener); 50 | } 51 | }, 52 | 53 | /** 54 | * @method getState 55 | * @return {Object|null} The state object in history 56 | */ 57 | getState: function () { 58 | return (this.win.history && this.win.history.state) || null; 59 | }, 60 | 61 | /** 62 | * Gets the path string, including the pathname and search query (if it exists). 63 | * @method getUrl 64 | * @return {String} The url string that denotes current route path and query 65 | */ 66 | getUrl: function () { 67 | var location = this.win.location; 68 | return location.pathname + location.search; 69 | }, 70 | 71 | /** 72 | * Same as HTML5 pushState API, but with old browser support 73 | * @method pushState 74 | * @param {Object} state The state object 75 | * @param {String} title The title string 76 | * @param {String} url The new url 77 | */ 78 | pushState: function (state, title, url) { 79 | var win = this.win; 80 | if (this._hasPushState) { 81 | title = isUndefined(title) ? win.document.title : title; 82 | url = isUndefined(url) ? win.location.href : url; 83 | win.history.pushState(state, title, url); 84 | this.setTitle(title); 85 | } else if (url) { 86 | win.location.href = url; 87 | } 88 | }, 89 | 90 | /** 91 | * Same as HTML5 replaceState API, but with old browser support 92 | * @method replaceState 93 | * @param {Object} state The state object 94 | * @param {String} title The title string 95 | * @param {String} url The new url 96 | */ 97 | replaceState: function (state, title, url) { 98 | var win = this.win; 99 | if (this._hasPushState) { 100 | title = isUndefined(title) ? win.document.title : title; 101 | url = isUndefined(url) ? win.location.href : url; 102 | win.history.replaceState(state, title, url); 103 | this.setTitle(title); 104 | } else if (url) { 105 | win.location.replace(url); 106 | } 107 | }, 108 | 109 | /** 110 | * Sets document title. No-op if title is empty. 111 | * @param {String} title The title string. 112 | */ 113 | setTitle: function (title) { 114 | if (title) { 115 | this.win.document.title = title; 116 | } 117 | } 118 | }; 119 | 120 | module.exports = History; 121 | -------------------------------------------------------------------------------- /lib/NavLink.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*global window */ 6 | 'use strict'; 7 | 8 | var React = require('react'); 9 | var NavLink; 10 | var navigateAction = require('../actions/navigate'); 11 | var debug = require('debug')('NavLink'); 12 | var objectAssign = require('object-assign'); 13 | 14 | function isLeftClickEvent (e) { 15 | return e.button === 0; 16 | } 17 | 18 | function isModifiedEvent (e) { 19 | return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey); 20 | } 21 | 22 | NavLink = React.createClass({ 23 | displayName: 'NavLink', 24 | contextTypes: { 25 | executeAction: React.PropTypes.func, 26 | makePath: React.PropTypes.func 27 | }, 28 | propTypes: { 29 | context: React.PropTypes.object, 30 | href: React.PropTypes.string, 31 | routeName: React.PropTypes.string, 32 | navParams: React.PropTypes.object, 33 | followLink: React.PropTypes.bool, 34 | preserveScrollPosition: React.PropTypes.bool, 35 | replaceState: React.PropTypes.bool 36 | }, 37 | getInitialState: function () { 38 | return { 39 | href: this._getHrefFromProps(this.props) 40 | }; 41 | }, 42 | componentWillReceiveProps: function (nextProps) { 43 | this.setState({ 44 | href: this._getHrefFromProps(nextProps) 45 | }); 46 | }, 47 | _getHrefFromProps: function (props) { 48 | var href = props.href; 49 | var routeName = props.routeName; 50 | if (!href && routeName) { 51 | var context = props.context || this.context; 52 | href = context.makePath(routeName, props.navParams); 53 | } 54 | if (!href) { 55 | throw new Error('NavLink created without href or unresolvable routeName \'' + routeName + '\''); 56 | } 57 | return href; 58 | }, 59 | dispatchNavAction: function (e) { 60 | var navType = this.props.replaceState ? 'replacestate' : 'click'; 61 | debug('dispatchNavAction: action=NAVIGATE', this.props.href, this.props.followLink, this.props.navParams); 62 | 63 | if (this.props.followLink) { 64 | return; 65 | } 66 | 67 | if (isModifiedEvent(e) || !isLeftClickEvent(e)) { 68 | // this is a click with a modifier or not a left-click 69 | // let browser handle it natively 70 | return; 71 | } 72 | 73 | var href = this.state.href; 74 | 75 | if (href[0] === '#') { 76 | // this is a hash link url for page's internal links. 77 | // Do not trigger navigate action. Let browser handle it natively. 78 | return; 79 | } 80 | 81 | if (href[0] !== '/') { 82 | // this is not a relative url. check for external urls. 83 | var location = window.location; 84 | var origin = location.origin || (location.protocol + '//' + location.host); 85 | 86 | if (href.indexOf(origin) !== 0) { 87 | // this is an external url, do not trigger navigate action. 88 | // let browser handle it natively. 89 | return; 90 | } 91 | 92 | href = href.substring(origin.length) || '/'; 93 | } 94 | 95 | var context = this.props.context || this.context; 96 | if (!context || !context.executeAction) { 97 | console.warn('NavLink does not have access to executeAction. Link using browser default.'); 98 | return; 99 | } 100 | 101 | e.preventDefault(); 102 | e.stopPropagation(); 103 | 104 | var onBeforeUnloadText = typeof window.onbeforeunload === 'function' ? window.onbeforeunload() : ''; 105 | var confirmResult = onBeforeUnloadText ? window.confirm(onBeforeUnloadText) : true; 106 | 107 | if (confirmResult) { 108 | // Removes the window.onbeforeunload method so that the next page will not be affected 109 | window.onbeforeunload = null; 110 | 111 | context.executeAction(navigateAction, { 112 | type: navType, 113 | url: href, 114 | preserveScrollPosition: this.props.preserveScrollPosition, 115 | params: this.props.navParams 116 | }); 117 | } 118 | }, 119 | render: function() { 120 | return React.createElement( 121 | 'a', 122 | objectAssign({}, { 123 | onClick: this.dispatchNavAction 124 | }, this.props, { 125 | href: this.state.href 126 | }), 127 | this.props.children 128 | ); 129 | } 130 | }); 131 | 132 | module.exports = NavLink; 133 | -------------------------------------------------------------------------------- /lib/RouterMixin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014-2015, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*global window */ 6 | 'use strict'; 7 | 8 | var debug = require('debug')('RouterMixin'); 9 | var navigateAction = require('../actions/navigate'); 10 | var History = require('./History'); 11 | var React = require('react'); 12 | var TYPE_CLICK = 'click'; 13 | var TYPE_PAGELOAD = 'pageload'; 14 | var TYPE_REPLACESTATE = 'replacestate'; 15 | var TYPE_POPSTATE = 'popstate'; 16 | var TYPE_DEFAULT = 'default'; // default value if navigation type is missing, for programmatic navigation 17 | var RouterMixin; 18 | 19 | require('setimmediate'); 20 | 21 | function routesEqual(route1, route2) { 22 | route1 = route1 || {}; 23 | route2 = route2 || {}; 24 | return (route1.url === route2.url); 25 | } 26 | 27 | function saveScrollPosition(e, history) { 28 | var historyState = (history.getState && history.getState()) || {}; 29 | historyState.scroll = {x: window.scrollX, y: window.scrollY}; 30 | debug('remember scroll position', historyState.scroll); 31 | history.replaceState(historyState); 32 | } 33 | 34 | RouterMixin = { 35 | contextTypes: { 36 | executeAction: React.PropTypes.func, 37 | makePath: React.PropTypes.func 38 | }, 39 | childContextTypes: { 40 | makePath: React.PropTypes.func 41 | }, 42 | getChildContext: function(){ 43 | var context = {}; 44 | Object.keys(RouterMixin.childContextTypes).forEach(function (key) { 45 | context[key] = (this.props.context && this.props.context[key]) || this.context[key]; 46 | }, this); 47 | return context; 48 | }, 49 | componentDidMount: function() { 50 | var self = this; 51 | var context; 52 | var urlFromHistory; 53 | var urlFromState; 54 | 55 | if (self.context && self.context.executeAction) { 56 | context = self.context; 57 | } else if (self.props.context && self.props.context.executeAction) { 58 | context = self.props.context; 59 | } 60 | 61 | self._history = ('function' === typeof self.props.historyCreator) ? self.props.historyCreator() : new History(); 62 | self._enableScroll = (self.props.enableScroll !== false); 63 | 64 | if (self.props.checkRouteOnPageLoad) { 65 | // You probably want to enable checkRouteOnPageLoad, if you use a history implementation 66 | // that supports hash route: 67 | // At page load, for browsers without pushState AND hash is present in the url, 68 | // since hash fragment is not sent to the server side, we need to 69 | // dispatch navigate action on browser side to load the actual page content 70 | // for the route represented by the hash fragment. 71 | 72 | urlFromHistory = self._history.getUrl(); 73 | urlFromState = self.state && self.state.route && self.state.route.url; 74 | 75 | if (context && (urlFromHistory !== urlFromState)) { 76 | // put it in setImmediate, because we need the base component to have 77 | // store listeners attached, before navigateAction is executed. 78 | debug('pageload navigate to actual route', urlFromHistory, urlFromState); 79 | setImmediate(function navigateToActualRoute() { 80 | context.executeAction(navigateAction, {type: TYPE_PAGELOAD, url: urlFromHistory}); 81 | }); 82 | } 83 | } 84 | 85 | self._historyListener = function (e) { 86 | debug('history listener invoked', e, url, self.state.route.url); 87 | 88 | if (context) { 89 | var state = self.state || {}; 90 | var url = self._history.getUrl(); 91 | var currentUrl = state.route.url; 92 | var route = state.route || {}; 93 | var onBeforeUnloadText = typeof window.onbeforeunload === 'function' ? window.onbeforeunload() : ''; 94 | var confirmResult = onBeforeUnloadText ? window.confirm(onBeforeUnloadText) : true; 95 | var nav = route.navigate || {}; 96 | var navParams = nav.params || {}; 97 | var enableScroll = self._enableScroll && nav.preserveScrollPosition; 98 | var historyState = { 99 | params: (nav.params || {}), 100 | scroll: { 101 | x: (enableScroll ? window.scrollX : 0), 102 | y: (enableScroll ? window.scrollY : 0) 103 | } 104 | }; 105 | var pageTitle = navParams.pageTitle || null; 106 | 107 | if (!confirmResult) { 108 | // Pushes the previous history state back on top to set the correct url 109 | self._history.pushState(historyState, pageTitle, currentUrl); 110 | } else { 111 | if (url !== currentUrl) { 112 | // Removes the window.onbeforeunload method so that the next page will not be affected 113 | window.onbeforeunload = null; 114 | 115 | context.executeAction(navigateAction, {type: TYPE_POPSTATE, url: url, params: (e.state && e.state.params)}); 116 | } 117 | } 118 | } 119 | }; 120 | self._history.on(self._historyListener); 121 | 122 | if (self._enableScroll) { 123 | var scrollTimer; 124 | self._scrollListener = function (e) { 125 | if (scrollTimer) { 126 | window.clearTimeout(scrollTimer); 127 | } 128 | scrollTimer = window.setTimeout(saveScrollPosition.bind(self, e, self._history), 150); 129 | }; 130 | window.addEventListener('scroll', self._scrollListener); 131 | } 132 | }, 133 | componentWillUnmount: function() { 134 | this._history.off(this._historyListener); 135 | this._historyListener = null; 136 | 137 | if (this._enableScroll) { 138 | window.removeEventListener('scroll', this._scrollListener); 139 | this._scrollListener = null; 140 | } 141 | 142 | this._history = null; 143 | }, 144 | componentDidUpdate: function (prevProps, prevState) { 145 | debug('component did update', prevState, this.state); 146 | 147 | var newState = this.state; 148 | if (routesEqual(prevState && prevState.route, newState && newState.route)) { 149 | return; 150 | } 151 | 152 | var nav = newState.route.navigate || {}; 153 | var navType = nav.type || TYPE_DEFAULT; 154 | var historyState; 155 | var pageTitle; 156 | 157 | switch (navType) { 158 | case TYPE_CLICK: 159 | case TYPE_DEFAULT: 160 | case TYPE_REPLACESTATE: 161 | historyState = {params: (nav.params || {})}; 162 | if(this._enableScroll) { 163 | if (nav.preserveScrollPosition) { 164 | historyState.scroll = {x: window.scrollX, y: window.scrollY}; 165 | } else { 166 | window.scrollTo(0, 0); 167 | historyState.scroll = {x: 0, y: 0}; 168 | debug('on click navigation, reset scroll position to (0, 0)'); 169 | } 170 | } 171 | pageTitle = nav.params && nav.params.pageTitle || null; 172 | if(navType == TYPE_REPLACESTATE) { 173 | this._history.replaceState(historyState, pageTitle, newState.route.url); 174 | } else { 175 | this._history.pushState(historyState, pageTitle, newState.route.url); 176 | } 177 | break; 178 | case TYPE_POPSTATE: 179 | if (this._enableScroll) { 180 | historyState = (this._history.getState && this._history.getState()) || {}; 181 | var scroll = (historyState && historyState.scroll) || {}; 182 | debug('on popstate navigation, restore scroll position to ', scroll); 183 | window.scrollTo(scroll.x || 0, scroll.y || 0); 184 | } 185 | break; 186 | } 187 | } 188 | }; 189 | 190 | module.exports = RouterMixin; 191 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flux-router-component", 3 | "version": "0.6.3", 4 | "description": "Router-related React component and mixin for applications with Fluxible architecture", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/yahoo/flux-router-component.git" 9 | }, 10 | "scripts": { 11 | "cover": "node node_modules/istanbul/lib/cli.js cover --dir artifacts -- ./node_modules/mocha/bin/_mocha tests/unit/ --recursive --reporter spec", 12 | "lint": "jshint lib tests", 13 | "test": "mocha tests/unit/ --recursive --reporter spec" 14 | }, 15 | "author": "Lingyan Zhu ", 16 | "licenses": [ 17 | { 18 | "type": "BSD", 19 | "url": "https://github.com/yahoo/flux-router-component/blob/master/LICENSE.md" 20 | } 21 | ], 22 | "dependencies": { 23 | "debug": "^2.0.0", 24 | "object-assign": "^2.0.0", 25 | "query-string": "^1.0.0", 26 | "setimmediate": "^1.0.2" 27 | }, 28 | "peerDependencies": { 29 | "react": "0.13.x" 30 | }, 31 | "devDependencies": { 32 | "chai": "^2.0.0", 33 | "coveralls": "^2.11.1", 34 | "istanbul": "^0.3.2", 35 | "jsdom": "^3.0.2", 36 | "jshint": "^2.5.1", 37 | "lodash": "^3.2.0", 38 | "mocha": "^2.0.1", 39 | "precommit-hook": "^1.0.2", 40 | "react": "0.13.x", 41 | "react-tools": "0.13.x" 42 | }, 43 | "jshintConfig": { 44 | "node": true 45 | }, 46 | "keywords": [ 47 | "flux", 48 | "history", 49 | "navigation", 50 | "react", 51 | "router" 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /tests/mocks/MockAppComponent.js: -------------------------------------------------------------------------------- 1 | var React = require('react/addons'); 2 | var RouterMixin = require('../../').RouterMixin; 3 | 4 | var MockAppComponent = React.createClass({ 5 | 6 | mixins: [RouterMixin], 7 | 8 | childContextTypes: { 9 | executeAction: React.PropTypes.func, 10 | getStore: React.PropTypes.func 11 | }, 12 | getChildContext: function () { 13 | return { 14 | executeAction: this.props.context.executeAction, 15 | getStore: this.props.context.getStore 16 | }; 17 | }, 18 | 19 | render: function () { 20 | return React.addons.cloneWithProps(this.props.children, {}); 21 | } 22 | }); 23 | 24 | module.exports = MockAppComponent; 25 | -------------------------------------------------------------------------------- /tests/mocks/mockWindow.js: -------------------------------------------------------------------------------- 1 | module.exports = function mockWindow(testResult) { 2 | return { 3 | HTML5: { 4 | document: {}, 5 | history: { 6 | pushState: function (state, title, url) { 7 | testResult.pushState = { 8 | state: state, 9 | title: title, 10 | url: url 11 | }; 12 | }, 13 | replaceState: function (state, title, url) { 14 | testResult.replaceState = { 15 | state: state, 16 | title: title, 17 | url: url 18 | }; 19 | } 20 | }, 21 | addEventListener: function (evt, listener) { 22 | testResult.addEventListener = { 23 | evt: evt, 24 | listener: listener 25 | }; 26 | }, 27 | removeEventListener: function (evt, listener) { 28 | testResult.removeEventListener = { 29 | evt: evt, 30 | listener: listener 31 | }; 32 | } 33 | }, 34 | Firefox: { 35 | document: {}, 36 | history: { 37 | pushState: function (state, title, url) { 38 | if (arguments.length < 3) { 39 | throw new TypeError("Not enough arguments to History.pushState."); 40 | } 41 | testResult.pushState = { 42 | state: state, 43 | title: title, 44 | url: url 45 | }; 46 | }, 47 | replaceState: function (state, title, url) { 48 | if (arguments.length < 3) { 49 | throw new TypeError("Not enough arguments to History.replaceState."); 50 | } 51 | testResult.pushState = { 52 | state: state, 53 | title: title, 54 | url: url 55 | }; 56 | testResult.replaceState = { 57 | state: state, 58 | title: title, 59 | url: url 60 | }; 61 | } 62 | }, 63 | addEventListener: function (evt, listener) { 64 | testResult.addEventListener = { 65 | evt: evt, 66 | listener: listener 67 | }; 68 | }, 69 | removeEventListener: function (evt, listener) { 70 | testResult.removeEventListener = { 71 | evt: evt, 72 | listener: listener 73 | }; 74 | } 75 | }, 76 | OLD: { 77 | document: {}, 78 | addEventListener: function (evt, listener) { 79 | testResult.addEventListener = { 80 | evt: evt, 81 | listener: listener 82 | }; 83 | }, 84 | removeEventListener: function (evt, listener) { 85 | testResult.removeEventListener = { 86 | evt: evt, 87 | listener: listener 88 | }; 89 | } 90 | } 91 | }; 92 | 93 | }; 94 | -------------------------------------------------------------------------------- /tests/unit/actions/navigate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*globals describe,it,before,beforeEach */ 6 | var expect = require('chai').expect, 7 | navigateAction = require('../../../actions/navigate'), 8 | lodash = require('lodash'), 9 | urlParser = require('url'); 10 | 11 | describe('navigateAction', function () { 12 | var mockContext, 13 | actionCalls, 14 | homeRoute, 15 | actionRoute, 16 | failedRoute, 17 | stringActionRoute, 18 | fooAction; 19 | 20 | beforeEach(function () { 21 | homeRoute = {}; 22 | fooAction = function () {}; 23 | actionRoute = { 24 | config: { 25 | action: function () {} 26 | } 27 | }; 28 | failedRoute = { 29 | config: { 30 | action: function () {} 31 | } 32 | }; 33 | stringActionRoute = { 34 | config: { 35 | action: 'foo' 36 | } 37 | }; 38 | postMethodRoute = { 39 | config: { 40 | action: function () {}, 41 | method: 'post' 42 | } 43 | }; 44 | actionCalls = []; 45 | mockContext = { 46 | routerCalls: [], 47 | router: { 48 | getRoute: function (url, options) { 49 | mockContext.routerCalls.push(arguments); 50 | var parsed = url && urlParser.parse(url); 51 | var pathname = parsed && parsed.pathname; 52 | var route; 53 | if ('/' === pathname) { 54 | route = lodash.clone(homeRoute); 55 | } else if ('/action' === pathname) { 56 | route = lodash.clone(actionRoute); 57 | } else if ('/fail' === pathname) { 58 | route = lodash.clone(failedRoute); 59 | } else if ('/string' === pathname) { 60 | route = lodash.clone(stringActionRoute); 61 | } else if ('/post' === pathname && postMethodRoute.config.method === options.method) { 62 | route = lodash.clone(postMethodRoute); 63 | } 64 | if (route) { 65 | route.url = url; 66 | } 67 | return route || null; 68 | } 69 | }, 70 | executeActionCalls: [], 71 | getAction: function () { 72 | return fooAction; 73 | }, 74 | executeAction: function(action, route, done) { 75 | mockContext.executeActionCalls.push(arguments); 76 | if (failedRoute.config.action === action) { 77 | done(new Error('test')); 78 | return; 79 | } 80 | done(); 81 | }, 82 | dispatchCalls: [], 83 | dispatch: function () { 84 | mockContext.dispatchCalls.push(arguments); 85 | } 86 | }; 87 | }); 88 | 89 | it ('should not call anything if the router is not set', function () { 90 | mockContext.router = undefined; 91 | navigateAction(mockContext, { 92 | url: '/' 93 | }, function () { 94 | expect(mockContext.routerCalls.length).to.equal(0); 95 | expect(mockContext.executeActionCalls.length).to.equal(0); 96 | expect(mockContext.dispatchCalls.length).to.equal(0); 97 | }); 98 | }); 99 | 100 | it ('should dispatch on route match', function () { 101 | navigateAction(mockContext, { 102 | url: '/' 103 | }, function (err) { 104 | expect(err).to.equal(undefined); 105 | expect(mockContext.routerCalls.length).to.equal(1); 106 | expect(mockContext.dispatchCalls.length).to.equal(2); 107 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START'); 108 | expect(mockContext.dispatchCalls[0][1].url).to.equal('/'); 109 | expect(mockContext.dispatchCalls[0][1].query).to.eql({}); 110 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS'); 111 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/'); 112 | }); 113 | }); 114 | 115 | it ('should include query param on route match', function () { 116 | var url = '/?foo=bar&a=b&a=c&bool#abcd=fff'; 117 | navigateAction(mockContext, { 118 | url: url 119 | }, function (err) { 120 | expect(err).to.equal(undefined); 121 | expect(mockContext.routerCalls.length).to.equal(1); 122 | expect(mockContext.dispatchCalls.length).to.equal(2); 123 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START'); 124 | var route = mockContext.dispatchCalls[0][1]; 125 | expect(route.url).to.equal(url); 126 | expect(route.query).to.eql({foo: 'bar', a: ['b', 'c'], bool: null}, 'query added to route payload for CHANGE_ROUTE_START' + JSON.stringify(route)); 127 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS'); 128 | route = mockContext.dispatchCalls[1][1]; 129 | expect(route.url).to.equal(url); 130 | expect(route.query).to.eql({foo: 'bar', a: ['b', 'c'], bool: null}, 'query added to route payload for CHANGE_ROUTE_SUCCESS'); 131 | }); 132 | }); 133 | 134 | it ('should not call execute action if there is no action', function () { 135 | navigateAction(mockContext, { 136 | url: '/' 137 | }, function () { 138 | expect(mockContext.executeActionCalls.length).to.equal(0); 139 | }); 140 | }); 141 | 142 | it ('should call execute action if there is a action', function () { 143 | navigateAction(mockContext, { 144 | url: '/action' 145 | }, function (err) { 146 | expect(err).to.equal(undefined); 147 | expect(mockContext.dispatchCalls.length).to.equal(2); 148 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS'); 149 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/action'); 150 | expect(mockContext.executeActionCalls.length).to.equal(1); 151 | expect(mockContext.executeActionCalls[0][0]).to.equal(actionRoute.config.action); 152 | expect(mockContext.executeActionCalls[0][1].url).to.equal('/action'); 153 | expect(mockContext.executeActionCalls[0][2]).to.be.a('function'); 154 | }); 155 | }); 156 | 157 | it ('should call execute action if there is an action as a string', function () { 158 | navigateAction(mockContext, { 159 | url: '/string' 160 | }, function (err) { 161 | expect(err).to.equal(undefined); 162 | expect(mockContext.dispatchCalls.length).to.equal(2); 163 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS'); 164 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/string'); 165 | expect(mockContext.executeActionCalls.length).to.equal(1); 166 | expect(mockContext.executeActionCalls[0][0]).to.equal(fooAction); 167 | expect(mockContext.executeActionCalls[0][1].url).to.equal('/string'); 168 | expect(mockContext.executeActionCalls[0][2]).to.be.a('function'); 169 | }); 170 | }); 171 | 172 | it ('should dispatch failure if action failed', function () { 173 | navigateAction(mockContext, { 174 | url: '/fail' 175 | }, function (err) { 176 | expect(err).to.be.an('object'); 177 | expect(mockContext.dispatchCalls.length).to.equal(2); 178 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_FAILURE'); 179 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/fail'); 180 | }); 181 | }); 182 | 183 | it ('should call back with a 404 error if route not found', function () { 184 | navigateAction(mockContext, { 185 | url: '/404' 186 | }, function (err) { 187 | expect(mockContext.routerCalls.length).to.equal(1); 188 | expect(err).to.be.an('object'); 189 | expect(err.status).to.equal(404); 190 | }); 191 | }); 192 | 193 | it ('should call back with a 404 error if url matches but not method', function () { 194 | navigateAction(mockContext, { 195 | url: '/post', 196 | method: 'get' 197 | }, function (err) { 198 | expect(mockContext.routerCalls.length).to.equal(1); 199 | expect(err).to.be.an('object'); 200 | expect(err.status).to.equal(404); 201 | }); 202 | }); 203 | 204 | it ('should dispatch if both url and method matches', function () { 205 | navigateAction(mockContext, { 206 | url: '/post', 207 | method: 'post' 208 | }, function (err) { 209 | expect(err).to.equal(undefined); 210 | expect(mockContext.routerCalls.length).to.equal(1); 211 | expect(mockContext.dispatchCalls.length).to.equal(2); 212 | expect(mockContext.dispatchCalls[0][0]).to.equal('CHANGE_ROUTE_START'); 213 | expect(mockContext.dispatchCalls[0][1].url).to.equal('/post'); 214 | expect(mockContext.dispatchCalls[0][1].query).to.eql({}); 215 | expect(mockContext.dispatchCalls[1][0]).to.equal('CHANGE_ROUTE_SUCCESS'); 216 | expect(mockContext.dispatchCalls[1][1].url).to.equal('/post'); 217 | }); 218 | }); 219 | }); 220 | -------------------------------------------------------------------------------- /tests/unit/lib/History-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*globals describe,it,before,beforeEach */ 6 | var History = require('../../../lib/History'), 7 | expect = require('chai').expect, 8 | _ = require('lodash'), 9 | windowMock, 10 | testResult; 11 | 12 | 13 | describe('History', function () { 14 | 15 | beforeEach(function () { 16 | testResult = {}; 17 | windowMock = require('../../mocks/mockWindow')(testResult); 18 | }); 19 | 20 | describe('constructor', function () { 21 | it ('has pushState', function () { 22 | var history = new History({win: windowMock.HTML5}); 23 | expect(history.win).to.equal(windowMock.HTML5); 24 | expect(history._hasPushState).to.equal(true); 25 | }); 26 | it ('no pushState', function () { 27 | var history = new History({win: windowMock.OLD}); 28 | expect(history.win).to.equal(windowMock.OLD); 29 | expect(history._hasPushState).to.equal(false); 30 | }); 31 | }); 32 | 33 | describe('on', function () { 34 | it ('has pushState', function () { 35 | var history = new History({win: windowMock.HTML5}); 36 | var listener = function () {}; 37 | history.on(listener); 38 | expect(testResult.addEventListener).to.eql({evt: 'popstate', listener: listener}); 39 | }); 40 | it ('no pushState', function () { 41 | var history = new History({win: windowMock.OLD}); 42 | var listener = function () {}; 43 | history.on(listener); 44 | expect(testResult.addEventListener).to.eql(undefined); 45 | }); 46 | }); 47 | 48 | describe('off', function () { 49 | it ('has pushState', function () { 50 | var history = new History({win: windowMock.HTML5}); 51 | var listener = function () {}; 52 | history.off(listener); 53 | expect(testResult.removeEventListener).to.eql({evt: 'popstate', listener: listener}); 54 | }); 55 | it ('no pushState', function () { 56 | var history = new History({win: windowMock.OLD}); 57 | var listener = function () {}; 58 | history.off(listener); 59 | expect(testResult.removeEventListener).to.eql(undefined); 60 | }); 61 | }); 62 | 63 | describe('getState', function () { 64 | it ('has pushState', function () { 65 | var history = new History({win: _.merge({history: {state: {foo: 'bar'}}}, windowMock.HTML5)}); 66 | expect(history.getState()).to.eql({foo: 'bar'}); 67 | }); 68 | it ('no pushState', function () { 69 | var history = new History({win: windowMock.OLD}); 70 | expect(history.getState()).to.eql(null); 71 | }); 72 | }); 73 | 74 | describe('getUrl', function () { 75 | it ('has pushState', function () { 76 | var win = _.extend(windowMock.HTML5, { 77 | location: { 78 | pathname: '/path/to/page', 79 | search: '', 80 | hash: '#/path/to/abc' 81 | } 82 | }); 83 | var history = new History({win: win}); 84 | var url = history.getUrl(); 85 | expect(url).to.equal('/path/to/page'); 86 | }); 87 | it ('has pushState with query', function () { 88 | var win = _.extend(windowMock.HTML5, { 89 | location: { 90 | pathname: '/path/to/page', 91 | search: '?foo=bar&x=y' 92 | } 93 | }); 94 | var history = new History({win: win}); 95 | var url = history.getUrl(); 96 | expect(url).to.equal('/path/to/page?foo=bar&x=y'); 97 | }); 98 | it ('no pushState', function () { 99 | var win, history, url; 100 | win = _.extend(windowMock.OLD, { 101 | location: { 102 | pathname: '/path/to/page', 103 | hash: '#/path/to/abc', 104 | search: '' 105 | } 106 | }); 107 | history = new History({win: win}); 108 | url = history.getUrl(); 109 | expect(url).to.equal('/path/to/page'); 110 | 111 | win = _.extend(windowMock.OLD, { 112 | location: { 113 | pathname: '/path/to/page', 114 | hash: '#', 115 | search: '' 116 | } 117 | }); 118 | history = new History({win: win}); 119 | url = history.getUrl(); 120 | expect(url).to.equal('/path/to/page', 'hash=#'); 121 | 122 | win = _.extend(windowMock.OLD, { 123 | location: { 124 | pathname: '/path/to/page', 125 | hash: '', 126 | search: '' 127 | } 128 | }); 129 | history = new History({win: win}); 130 | url = history.getUrl(); 131 | expect(url).to.equal('/path/to/page'); 132 | }); 133 | it ('no pushState, with query', function () { 134 | var win, history, url; 135 | win = _.extend(windowMock.OLD, { 136 | location: { 137 | pathname: '/path/to/page', 138 | search: '?foo=bar&x=y', 139 | hash: '#xyz' 140 | } 141 | }); 142 | history = new History({win: win}); 143 | url = history.getUrl(); 144 | expect(url).to.equal('/path/to/page?foo=bar&x=y'); 145 | }); 146 | }); 147 | 148 | describe('pushState', function () { 149 | it ('has pushState', function () { 150 | var win = _.extend(windowMock.HTML5, { 151 | 'document': { 152 | title: 'current title' 153 | }, 154 | location: { 155 | href: '/currentUrl' 156 | } 157 | }); 158 | var history = new History({win: win}); 159 | 160 | history.pushState({foo: 'bar'}); 161 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 162 | expect(testResult.pushState.title).to.equal('current title'); 163 | expect(testResult.pushState.url).to.equal('/currentUrl'); 164 | 165 | history.pushState({foo: 'bar'}, 't'); 166 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 167 | expect(testResult.pushState.title).to.equal('t'); 168 | expect(testResult.pushState.url).to.equal('/currentUrl'); 169 | 170 | history.pushState({foo: 'bar'}, 't', '/url'); 171 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 172 | expect(testResult.pushState.title).to.equal('t'); 173 | expect(testResult.pushState.url).to.equal('/url'); 174 | expect(windowMock.HTML5.document.title).to.equal('t'); 175 | 176 | history.pushState({foo: 'bar'}, 'tt', '/url?a=b&x=y'); 177 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 178 | expect(testResult.pushState.title).to.equal('tt'); 179 | expect(testResult.pushState.url).to.equal('/url?a=b&x=y'); 180 | expect(windowMock.HTML5.document.title).to.equal('tt'); 181 | }); 182 | it ('has pushState, Firefox', function () { 183 | var win = _.extend(windowMock.Firefox, { 184 | 'document': { 185 | title: 'current title' 186 | }, 187 | location: { 188 | href: '/currentUrl' 189 | } 190 | }); 191 | var history = new History({win: win}); 192 | 193 | history.pushState({foo: 'bar'}); 194 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 195 | expect(testResult.pushState.title).to.equal('current title'); 196 | expect(testResult.pushState.url).to.equal('/currentUrl'); 197 | 198 | history.pushState({foo: 'bar'}, 't'); 199 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 200 | expect(testResult.pushState.title).to.equal('t'); 201 | expect(testResult.pushState.url).to.equal('/currentUrl'); 202 | 203 | history.pushState({foo: 'bar'}, 't', '/url'); 204 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 205 | expect(testResult.pushState.title).to.equal('t'); 206 | expect(testResult.pushState.url).to.equal('/url'); 207 | }); 208 | it ('no pushState', function () { 209 | var win = _.extend(windowMock.OLD, { 210 | location: {} 211 | }); 212 | var history = new History({win: win}); 213 | 214 | history.pushState({foo: 'bar'}, 't', '/url'); 215 | expect(win.location.href).to.equal('/url'); 216 | 217 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y'); 218 | expect(win.location.href).to.equal('/url?a=b&x=y'); 219 | 220 | history.pushState({foo: 'bar'}); 221 | expect(win.location.href).to.equal('/url?a=b&x=y'); 222 | }); 223 | }); 224 | 225 | describe('replaceState', function () { 226 | it ('has pushState', function () { 227 | var win = _.extend(windowMock.HTML5, { 228 | 'document': { 229 | title: 'current title' 230 | }, 231 | location: { 232 | href: '/currentUrl' 233 | } 234 | }); 235 | var history = new History({win: win}); 236 | 237 | history.replaceState({foo: 'bar'}); 238 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 239 | expect(testResult.replaceState.title).to.equal('current title'); 240 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 241 | 242 | history.replaceState({foo: 'bar'}, 't'); 243 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 244 | expect(testResult.replaceState.title).to.equal('t'); 245 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 246 | 247 | history.replaceState({foo: 'bar'}, 't', '/url'); 248 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 249 | expect(testResult.replaceState.title).to.equal('t'); 250 | expect(testResult.replaceState.url).to.equal('/url'); 251 | expect(windowMock.HTML5.document.title).to.equal('t'); 252 | 253 | history.replaceState({foo: 'bar'}, 'tt', '/url?a=b&x=y'); 254 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 255 | expect(testResult.replaceState.title).to.equal('tt'); 256 | expect(testResult.replaceState.url).to.equal('/url?a=b&x=y', 'url has query'); 257 | expect(windowMock.HTML5.document.title).to.equal('tt'); 258 | }); 259 | it ('has pushState, Firefox', function () { 260 | var win = _.extend(windowMock.Firefox, { 261 | 'document': { 262 | title: 'current title' 263 | }, 264 | location: { 265 | href: '/currentUrl' 266 | } 267 | }); 268 | var history = new History({win: win}); 269 | 270 | history.replaceState({foo: 'bar'}); 271 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 272 | expect(testResult.replaceState.title).to.equal('current title'); 273 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 274 | 275 | history.replaceState({foo: 'bar'}, 't'); 276 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 277 | expect(testResult.replaceState.title).to.equal('t'); 278 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 279 | 280 | history.replaceState({foo: 'bar'}, 't', '/url'); 281 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 282 | expect(testResult.replaceState.title).to.equal('t'); 283 | expect(testResult.replaceState.url).to.equal('/url'); 284 | }); 285 | it ('no pushState', function () { 286 | var win = _.extend(windowMock.OLD, { 287 | location: { 288 | replace: function(url) { 289 | testResult.locationReplace = {url: url}; 290 | } 291 | } 292 | }); 293 | var history = new History({win: win}); 294 | history.replaceState({foo: 'bar'}, 't', '/url'); 295 | expect(testResult.locationReplace.url).to.equal('/url'); 296 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y'); 297 | expect(testResult.locationReplace.url).to.equal('/url?a=b&x=y'); 298 | testResult.locationReplace.url = null; 299 | history.replaceState({foo: 'bar'}); 300 | expect(testResult.locationReplace.url).to.equal(null); 301 | }); 302 | }); 303 | 304 | }); 305 | -------------------------------------------------------------------------------- /tests/unit/lib/NavLink-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*globals describe,it,before,beforeEach */ 6 | var React; 7 | var NavLink; 8 | var ReactTestUtils; 9 | var jsdom = require('jsdom'); 10 | var expect = require('chai').expect; 11 | var contextMock; 12 | var onClickMock; 13 | var routerMock; 14 | var testResult; 15 | 16 | onClickMock = function () { 17 | testResult.onClickMockInvoked = true; 18 | }; 19 | 20 | routerMock = { 21 | makePath: function (name, params) { 22 | var paths = ['/' + name]; 23 | if (params) { 24 | Object.keys(params).sort().forEach(function (key) { 25 | paths.push('/' + key + '/' + params[key]); 26 | }); 27 | } 28 | return paths.join(''); 29 | } 30 | }; 31 | 32 | contextMock = { 33 | executeAction: function (action, payload) { 34 | testResult.dispatch = { 35 | action: 'NAVIGATE', 36 | payload: payload 37 | }; 38 | }, 39 | makePath: routerMock.makePath.bind(routerMock) 40 | }; 41 | 42 | describe('NavLink', function () { 43 | 44 | beforeEach(function () { 45 | global.document = jsdom.jsdom(''); 46 | global.window = global.document.parentWindow; 47 | global.navigator = global.window.navigator; 48 | React = require('react/addons'); 49 | ReactTestUtils = React.addons.TestUtils; 50 | NavLink = React.createFactory(require('../../../lib/NavLink')); 51 | Wrapper = React.createFactory(require('../../mocks/MockAppComponent')); 52 | testResult = {}; 53 | }); 54 | 55 | afterEach(function () { 56 | delete global.window; 57 | delete global.document; 58 | delete global.navigator; 59 | }); 60 | 61 | describe('render()', function () { 62 | it ('href defined', function () { 63 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", context:contextMock}, React.DOM.span(null, "bar"))); 64 | expect(link.props.href).to.equal('/foo'); 65 | expect(link.getDOMNode().textContent).to.equal('bar'); 66 | }); 67 | it ('both href and routeName defined', function () { 68 | var link = ReactTestUtils.renderIntoDocument(NavLink( {routeName:"fooo", href:"/foo", context:contextMock}, React.DOM.span(null, "bar"))); 69 | expect(link.props.href).to.equal('/foo'); 70 | }); 71 | it ('only routeName defined', function () { 72 | var navParams = {a: 1, b: 2}; 73 | var link = React.renderToString(NavLink( {routeName:"foo", navParams:navParams, context:contextMock}, React.DOM.span(null, "bar"))); 74 | expect(link).to.contain('href="/foo/a/1/b/2"'); 75 | }); 76 | it ('only routeName defined; use this.context.makePath', function (done) { 77 | var navParams = {a: 1, b: 2}; 78 | var link = React.renderToString(Wrapper({ 79 | context: contextMock 80 | }, NavLink({routeName:"foo", navParams:navParams}))); 81 | expect(link).to.contain('href="/foo/a/1/b/2"'); 82 | done(); 83 | }); 84 | it ('none defined', function () { 85 | var navParams = {a: 1, b: 2}; 86 | expect(function () { 87 | ReactTestUtils.renderIntoDocument(NavLink( {navParams:navParams, context:contextMock}, React.DOM.span(null, "bar"))); 88 | }).to.throw(); 89 | }); 90 | }); 91 | 92 | describe('dispatchNavAction()', function () { 93 | it ('use react context', function (done) { 94 | var navParams = {a: 1, b: true}; 95 | var link = ReactTestUtils.renderIntoDocument(NavLink({ 96 | href:"/foo", 97 | preserveScrollPosition: true, 98 | navParams: navParams 99 | }, React.DOM.span(null, "bar"))); 100 | link.context = contextMock; 101 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 102 | window.setTimeout(function () { 103 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 104 | expect(testResult.dispatch.payload.type).to.equal('click'); 105 | expect(testResult.dispatch.payload.url).to.equal('/foo'); 106 | expect(testResult.dispatch.payload.preserveScrollPosition).to.equal(true); 107 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true}); 108 | done(); 109 | }, 10); 110 | }); 111 | it ('context.executeAction called for relative urls', function (done) { 112 | var navParams = {a: 1, b: true}; 113 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar"))); 114 | link.context = contextMock; 115 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 116 | window.setTimeout(function () { 117 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 118 | expect(testResult.dispatch.payload.type).to.equal('click'); 119 | expect(testResult.dispatch.payload.url).to.equal('/foo'); 120 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true}); 121 | done(); 122 | }, 10); 123 | }); 124 | it ('context.executeAction called for routeNames', function (done) { 125 | var link = ReactTestUtils.renderIntoDocument(NavLink( {routeName:"foo", context: contextMock}, React.DOM.span(null, "bar"))); 126 | link.context = contextMock; 127 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 128 | window.setTimeout(function () { 129 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 130 | expect(testResult.dispatch.payload.type).to.equal('click'); 131 | expect(testResult.dispatch.payload.url).to.equal('/foo'); 132 | done(); 133 | }, 10); 134 | }); 135 | it ('context.executeAction called for absolute urls from same origin', function (done) { 136 | var navParams = {a: 1, b: true}; 137 | var origin = window.location.origin; 138 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin + "/foo?x=y", navParams:navParams}, React.DOM.span(null, "bar"))); 139 | link.context = contextMock; 140 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 141 | window.setTimeout(function () { 142 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 143 | expect(testResult.dispatch.payload.type).to.equal('click'); 144 | expect(testResult.dispatch.payload.url).to.equal('/foo?x=y'); 145 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true}); 146 | done(); 147 | }, 10); 148 | }); 149 | it ('context.executeAction not called if context does not exist', function (done) { 150 | var navParams = {a: 1, b: true}; 151 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar"))); 152 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 153 | window.setTimeout(function () { 154 | expect(testResult.dispatch).to.equal(undefined); 155 | done(); 156 | }, 10); 157 | }); 158 | it ('context.executeAction not called for external urls', function (done) { 159 | var navParams = {a: 1, b: true}; 160 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"http://domain.does.not.exist/foo", navParams:navParams}, React.DOM.span(null, "bar"))); 161 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 162 | window.setTimeout(function () { 163 | expect(testResult.dispatch).to.equal(undefined); 164 | done(); 165 | }, 10); 166 | }); 167 | it ('context.executeAction not called for # urls', function (done) { 168 | var navParams = {a: 1, b: true}; 169 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"#here", navParams:navParams}, React.DOM.span(null, "bar"))); 170 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 171 | window.setTimeout(function () { 172 | expect(testResult.dispatch).to.equal(undefined); 173 | done(); 174 | }, 10); 175 | }); 176 | it ('context.executeAction not called if followLink=true', function (done) { 177 | var navParams = {a: 1, b: true}; 178 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/somepath", navParams:navParams, followLink:true}, React.DOM.span(null, "bar"))); 179 | link.context = contextMock; 180 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 181 | window.setTimeout(function () { 182 | expect(testResult.dispatch).to.equal(undefined); 183 | done(); 184 | }, 1000); 185 | }); 186 | it ('context.executeAction called if followLink=false', function (done) { 187 | var navParams = {a: 1, b: true}; 188 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams, followLink:false}, React.DOM.span(null, "bar"))); 189 | link.context = contextMock; 190 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 191 | window.setTimeout(function () { 192 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 193 | expect(testResult.dispatch.payload.type).to.equal('click'); 194 | expect(testResult.dispatch.payload.url).to.equal('/foo'); 195 | expect(testResult.dispatch.payload.params).to.eql({a: 1, b: true}); 196 | done(); 197 | }, 10); 198 | }); 199 | 200 | describe('window.onbeforeunload', function () { 201 | beforeEach(function () { 202 | global.window.confirm = function () { return false; }; 203 | global.window.onbeforeunload = function () { 204 | return 'this is a test'; 205 | }; 206 | }); 207 | 208 | it ('should not call context.executeAction when a user does not confirm the onbeforeunload method', function (done) { 209 | var navParams = {a: 1, b: true}; 210 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", navParams:navParams}, React.DOM.span(null, "bar"))); 211 | link.context = contextMock; 212 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 213 | window.setTimeout(function () { 214 | expect(testResult).to.deep.equal({}); 215 | done(); 216 | }, 10); 217 | }); 218 | }); 219 | }); 220 | 221 | describe('click type', function () { 222 | it('navigates on regular click', function (done) { 223 | var origin = window.location.origin; 224 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin, context:contextMock}, React.DOM.span(null, "bar"))); 225 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 226 | window.setTimeout(function () { 227 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 228 | expect(testResult.dispatch.payload.type).to.equal('click'); 229 | done(); 230 | }, 10); 231 | }); 232 | 233 | it('navigates on regular click using replaceState', function (done) { 234 | var origin = window.location.origin; 235 | var link = ReactTestUtils.renderIntoDocument( 236 | NavLink( 237 | {href: origin, replaceState: true, context:contextMock}, 238 | React.DOM.span(null, "bar") 239 | ) 240 | ); 241 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 242 | window.setTimeout(function () { 243 | expect(testResult.dispatch.action).to.equal('NAVIGATE'); 244 | expect(testResult.dispatch.payload.type).to.equal('replacestate'); 245 | done(); 246 | }, 10); 247 | }); 248 | 249 | ['metaKey', 'altKey', 'ctrlKey', 'shiftKey'].map(function (key) { 250 | it('does not navigate on modified ' + key, function (done) { 251 | var eventData = {button: 0}; 252 | eventData[key] = true; 253 | var origin = window.location.origin; 254 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href: origin, context:contextMock}, React.DOM.span(null, "bar"))); 255 | ReactTestUtils.Simulate.click(link.getDOMNode(), eventData); 256 | window.setTimeout(function () { 257 | expect(testResult.dispatch).to.equal(undefined); 258 | done(); 259 | }, 10); 260 | }); 261 | }); 262 | 263 | }); 264 | 265 | it('allow overriding onClick', function (done) { 266 | var navParams = {a: 1, b: true}; 267 | var link = ReactTestUtils.renderIntoDocument(NavLink( {href:"/foo", context:contextMock, navParams:navParams, onClick: onClickMock}, React.DOM.span(null, "bar"))); 268 | expect(testResult.onClickMockInvoked).to.equal(undefined); 269 | ReactTestUtils.Simulate.click(link.getDOMNode(), {button: 0}); 270 | window.setTimeout(function () { 271 | expect(testResult.dispatch).to.equal(undefined); 272 | expect(testResult.onClickMockInvoked).to.equal(true); 273 | done(); 274 | }, 10); 275 | }); 276 | }); 277 | -------------------------------------------------------------------------------- /tests/unit/lib/RouterMixin-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*globals describe,it,before,beforeEach */ 6 | var expect = require('chai').expect; 7 | var routerMixin; 8 | var contextMock; 9 | var historyMock; 10 | var scrollToMock; 11 | var jsdom = require('jsdom'); 12 | var lodash = require('lodash'); 13 | var React = require('react'); 14 | var testResult; 15 | 16 | contextMock = { 17 | executeAction: function (action, payload) { 18 | testResult.dispatch = { 19 | action: action, 20 | payload: payload 21 | }; 22 | }, 23 | makePath: function(){} 24 | }; 25 | 26 | historyMock = function (url, state) { 27 | return { 28 | getUrl: function () { 29 | return url || '/the_path_from_history'; 30 | }, 31 | getState: function () { 32 | return state; 33 | }, 34 | on: function (listener) { 35 | testResult.historyMockOn = listener; 36 | }, 37 | pushState: function (state, title, url) { 38 | testResult.pushState = { 39 | state: state, 40 | title: title, 41 | url: url 42 | }; 43 | }, 44 | replaceState: function (state, title, url) { 45 | testResult.replaceState = { 46 | state: state, 47 | title: title, 48 | url: url 49 | }; 50 | } 51 | }; 52 | }; 53 | 54 | scrollToMock = function (x, y) { 55 | testResult.scrollTo = {x: x, y: y}; 56 | }; 57 | 58 | describe ('RouterMixin', function () { 59 | 60 | beforeEach(function () { 61 | routerMixin = require('../../../lib/RouterMixin'); 62 | routerMixin.props = {context: contextMock}; 63 | routerMixin.state = { 64 | route: {} 65 | }; 66 | global.document = jsdom.jsdom(''); 67 | global.window = global.document.parentWindow; 68 | global.navigator = global.window.navigator; 69 | global.window.scrollTo = scrollToMock; 70 | testResult = {}; 71 | }); 72 | 73 | afterEach(function () { 74 | delete global.window; 75 | delete global.document; 76 | delete global.navigator; 77 | }); 78 | 79 | it('contextTypes defined', function () { 80 | expect(routerMixin.contextTypes.executeAction).to.equal(React.PropTypes.func); 81 | expect(routerMixin.contextTypes.makePath).to.equal(React.PropTypes.func); 82 | }); 83 | 84 | it('childContextTypes defined', function () { 85 | expect(routerMixin.childContextTypes.makePath).to.equal(React.PropTypes.func); 86 | }); 87 | 88 | describe('getChildContext()', function() { 89 | it ('exposes childContextTypes to child context', function() { 90 | routerMixin.props = {context: contextMock }; 91 | var childContext = routerMixin.getChildContext(); 92 | Object.keys(routerMixin.childContextTypes).forEach(function (key) { 93 | expect(childContext[key]).to.be.a('function'); 94 | }, this); 95 | }); 96 | }); 97 | 98 | describe('componentDidMount()', function () { 99 | it ('listen to popstate event', function () { 100 | routerMixin.componentDidMount(); 101 | expect(routerMixin._historyListener).to.be.a('function'); 102 | window.dispatchEvent({_type: 'popstate', state: {params: {a: 1}}}); 103 | expect(testResult.dispatch.action).to.be.a('function'); 104 | expect(testResult.dispatch.payload.type).to.equal('popstate'); 105 | expect(testResult.dispatch.payload.url).to.equal(window.location.pathname); 106 | expect(testResult.dispatch.payload.params).to.eql({a: 1}); 107 | }); 108 | it ('listen to scroll event', function (done) { 109 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 110 | routerMixin.componentDidMount(); 111 | expect(routerMixin._scrollListener).to.be.a('function'); 112 | window.dispatchEvent({_type: 'scroll'}); 113 | window.dispatchEvent({_type: 'scroll'}); 114 | window.setTimeout(function() { 115 | expect(testResult.replaceState).to.eql({state: {scroll: {x: 0, y: 0}}, title: undefined, url: undefined}); 116 | done(); 117 | }, 250); 118 | }); 119 | it ('dispatch navigate event for pages that url does not match', function (done) { 120 | routerMixin.props = {context: contextMock, checkRouteOnPageLoad: true, historyCreator: function() { return historyMock(); }}; 121 | var origPushState = window.history.pushState; 122 | routerMixin.state = { 123 | route: { 124 | url: '/the_path_from_state' 125 | } 126 | }; 127 | routerMixin.componentDidMount(); 128 | window.setTimeout(function() { 129 | expect(testResult.dispatch.action).to.be.a('function'); 130 | expect(testResult.dispatch.payload.type).to.equal('pageload'); 131 | expect(testResult.dispatch.payload.url).to.equal('/the_path_from_history'); 132 | done(); 133 | }, 10); 134 | }); 135 | it ('dispatch navigate event for pages that url does not match; executeAction on this.context', function (done) { 136 | routerMixin.props = {checkRouteOnPageLoad: true, historyCreator: function() { return historyMock(); }}; 137 | routerMixin.context = contextMock; 138 | var origPushState = window.history.pushState; 139 | routerMixin.state = { 140 | route: { 141 | url: '/the_path_from_state' 142 | } 143 | }; 144 | routerMixin.componentDidMount(); 145 | window.setTimeout(function() { 146 | expect(testResult.dispatch.action).to.be.a('function'); 147 | expect(testResult.dispatch.payload.type).to.equal('pageload'); 148 | expect(testResult.dispatch.payload.url).to.equal('/the_path_from_history'); 149 | done(); 150 | }, 10); 151 | }); 152 | it ('does not dispatch navigate event for pages with matching url', function (done) { 153 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock(); }}; 154 | var origPushState = window.history.pushState; 155 | routerMixin.state = { 156 | route: { 157 | url: '/the_path_from_history' 158 | } 159 | }; 160 | routerMixin.componentDidMount(); 161 | window.setTimeout(function() { 162 | expect(testResult.dispatch).to.equal(undefined, JSON.stringify(testResult.dispatch)); 163 | done(); 164 | }, 10); 165 | }); 166 | 167 | describe('window.onbeforeunload', function () { 168 | beforeEach(function () { 169 | global.window.confirm = function () { return false; }; 170 | global.window.onbeforeunload = function () { 171 | return 'this is a test'; 172 | }; 173 | }); 174 | 175 | it ('should change the url back to the oldRoute if there is a window.onbeforeunload method', function (done) { 176 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock(); }}; 177 | var origPushState = window.history.pushState; 178 | routerMixin.state = { 179 | route: { 180 | url: '/the_path_from_history' 181 | } 182 | }; 183 | routerMixin.componentDidMount(); 184 | window.setTimeout(function() { 185 | expect(testResult.dispatch).to.equal(undefined, JSON.stringify(testResult.dispatch)); 186 | done(); 187 | }, 10); 188 | }); 189 | }); 190 | 191 | }); 192 | 193 | describe('componentWillUnmount()', function () { 194 | it ('stop listening to popstate event', function () { 195 | routerMixin.componentDidMount(); 196 | expect(routerMixin._historyListener).to.be.a('function'); 197 | routerMixin.componentWillUnmount(); 198 | expect(routerMixin._historyListener).to.equal(null); 199 | window.dispatchEvent({_type: 'popstate', state: {params: {a: 1}}}); 200 | expect(testResult.dispatch).to.equal(undefined); 201 | }); 202 | }); 203 | 204 | describe('componentDidUpdate()', function () { 205 | it ('no-op on same route', function () { 206 | var prevRoute = {url: '/foo'}, 207 | newRoute = {url: '/foo'}; 208 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 209 | routerMixin.state = {route: newRoute}; 210 | routerMixin.componentDidMount(); 211 | routerMixin.componentDidUpdate({}, {route: prevRoute}); 212 | expect(testResult.pushState).to.equal(undefined); 213 | }); 214 | it ('do not pushState, navigate.type=popstate', function () { 215 | var oldRoute = {url: '/foo'}, 216 | newRoute = {url: '/bar', navigate: {type: 'popstate'}}; 217 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 218 | routerMixin.state = {route: newRoute}; 219 | routerMixin.componentDidMount(); 220 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 221 | expect(testResult.pushState).to.equal(undefined); 222 | }); 223 | it ('update with different route, navigate.type=click, reset scroll position', function () { 224 | var oldRoute = {url: '/foo'}, 225 | newRoute = {url: '/bar', navigate: {type: 'click'}}; 226 | routerMixin.props = { 227 | context: contextMock, 228 | historyCreator: function() { 229 | return historyMock('/foo'); 230 | } 231 | }; 232 | routerMixin.state = {route: newRoute}; 233 | routerMixin.componentDidMount(); 234 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 235 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'}); 236 | expect(testResult.scrollTo).to.eql({x: 0, y: 0}); 237 | }); 238 | it ('update with different route, navigate.type=click, enableScroll=false, do not reset scroll position', function () { 239 | var oldRoute = {url: '/foo'}, 240 | newRoute = {url: '/bar', navigate: {type: 'click'}}; 241 | routerMixin.props = { 242 | context: contextMock, 243 | enableScroll: false, 244 | historyCreator: function() { 245 | return historyMock('/foo'); 246 | } 247 | }; 248 | routerMixin.state = {route: newRoute}; 249 | routerMixin.componentDidMount(); 250 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 251 | expect(testResult.pushState).to.eql({state: {params: {}}, title: null, url: '/bar'}); 252 | expect(testResult.scrollTo).to.equal(undefined); 253 | }); 254 | it ('update with different route, navigate.type=replacestate, enableScroll=false, do not reset scroll position', function () { 255 | var oldRoute = {url: '/foo'}, 256 | newRoute = {url: '/bar', navigate: {type: 'replacestate'}}; 257 | routerMixin.props = { 258 | context: contextMock, 259 | enableScroll: false, 260 | historyCreator: function() { 261 | return historyMock('/foo'); 262 | } 263 | }; 264 | routerMixin.state = {route: newRoute}; 265 | routerMixin.componentDidMount(); 266 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 267 | expect(testResult.replaceState).to.eql({state: {params: {}}, title: null, url: '/bar'}); 268 | expect(testResult.scrollTo).to.equal(undefined); 269 | }); 270 | it ('update with different route, navigate.type=default, reset scroll position', function () { 271 | var oldRoute = {url: '/foo'}, 272 | newRoute = {url: '/bar'}; 273 | routerMixin.props = { 274 | context: contextMock, 275 | historyCreator: function() { 276 | return historyMock('/foo'); 277 | } 278 | }; 279 | routerMixin.state = {route: newRoute}; 280 | routerMixin.componentDidMount(); 281 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 282 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'}); 283 | expect(testResult.scrollTo).to.eql({x: 0, y: 0}); 284 | }); 285 | it ('update with different route, navigate.type=default, enableScroll=false, do not reset scroll position', function () { 286 | var oldRoute = {url: '/foo'}, 287 | newRoute = {url: '/bar'}; 288 | routerMixin.props = { 289 | context: contextMock, 290 | enableScroll: false, 291 | historyCreator: function() { 292 | return historyMock('/foo'); 293 | } 294 | }; 295 | routerMixin.state = {route: newRoute}; 296 | routerMixin.componentDidMount(); 297 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 298 | expect(testResult.pushState).to.eql({state: {params: {}}, title: null, url: '/bar'}); 299 | expect(testResult.scrollTo).to.equal(undefined); 300 | }); 301 | it ('do not pushState, navigate.type=popstate, restore scroll position', function () { 302 | var oldRoute = {url: '/foo'}, 303 | newRoute = {url: '/bar', navigate: {type: 'popstate'}}; 304 | routerMixin.props = { 305 | context: contextMock, 306 | historyCreator: function() { 307 | return historyMock('/foo', {scroll: {x: 12, y: 200}}); 308 | } 309 | }; 310 | routerMixin.state = {route: newRoute}; 311 | routerMixin.componentDidMount(); 312 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 313 | expect(testResult.pushState).to.equal(undefined); 314 | expect(testResult.scrollTo).to.eql({x: 12, y: 200}); 315 | }); 316 | it ('do not pushState, navigate.type=popstate, enableScroll=false, restore scroll position', function () { 317 | var oldRoute = {url: '/foo'}, 318 | newRoute = {url: '/bar', navigate: {type: 'popstate'}}; 319 | routerMixin.props = { 320 | context: contextMock, 321 | enableScroll: false, 322 | historyCreator: function() { 323 | return historyMock('/foo', {scroll: {x: 12, y: 200}}); 324 | } 325 | }; 326 | routerMixin.state = {route: newRoute}; 327 | routerMixin.componentDidMount(); 328 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 329 | expect(testResult.pushState).to.equal(undefined); 330 | expect(testResult.scrollTo).to.eql(undefined); 331 | }); 332 | it ('update with different route, navigate.type=click, with params', function () { 333 | var oldRoute = {url: '/foo'}, 334 | newRoute = {url: '/bar', navigate: {type: 'click', params: {foo: 'bar'}}}; 335 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 336 | routerMixin.state = {route: newRoute}; 337 | routerMixin.componentDidMount(); 338 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 339 | expect(testResult.pushState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y:0}}, title: null, url: '/bar'}); 340 | }); 341 | it ('update with same path and different hash, navigate.type=click, with params', function () { 342 | var oldRoute = {url: '/foo#hash1'}, 343 | newRoute = {url: '/foo#hash2', navigate: {type: 'click', params: {foo: 'bar'}}}; 344 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo#hash1'); }}; 345 | routerMixin.state = {route: newRoute}; 346 | routerMixin.componentDidMount(); 347 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 348 | expect(testResult.pushState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y:0}}, title: null, url: '/foo#hash2'}); 349 | }); 350 | it ('update with different route, navigate.type=replacestate, with params', function () { 351 | var oldRoute = {url: '/foo'}, 352 | newRoute = {url: '/bar', navigate: {type: 'replacestate', params: {foo: 'bar'}}}; 353 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 354 | routerMixin.state = {route: newRoute}; 355 | routerMixin.componentDidMount(); 356 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 357 | expect(testResult.replaceState).to.eql({state: {params: {foo: 'bar'}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'}); 358 | }); 359 | it ('update with different route, navigate.type=replacestate', function () { 360 | var oldRoute = {url: '/foo'}, 361 | newRoute = {url: '/bar', navigate: {type: 'replacestate'}}; 362 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo', {scroll: {x: 42, y: 3}}); }}; 363 | routerMixin.state = {route: newRoute}; 364 | routerMixin.componentDidMount(); 365 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 366 | expect(testResult.replaceState).to.eql({state: {params: {}, scroll: {x: 0, y: 0}}, title: null, url: '/bar'}); 367 | }); 368 | it ('update with different route, navigate.type=pushstate, preserve scroll state', function () { 369 | var oldRoute = {url: '/foo'}, 370 | newRoute = {url: '/bar', navigate: {type: 'click', preserveScrollPosition: true}}; 371 | global.window.scrollX = 42; 372 | global.window.scrollY = 3; 373 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 374 | routerMixin.state = {route: newRoute}; 375 | routerMixin.componentDidMount(); 376 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 377 | expect(testResult.pushState).to.eql({state: {params: {}, scroll: {x: 42, y: 3}}, title: null, url: '/bar'}); 378 | }); 379 | it ('update with different route, navigate.type=replacestate, preserve scroll state', function () { 380 | var oldRoute = {url: '/foo'}, 381 | newRoute = {url: '/bar', navigate: {type: 'replacestate', preserveScrollPosition: true}}; 382 | global.window.scrollX = 42; 383 | global.window.scrollY = 3; 384 | routerMixin.props = {context: contextMock, historyCreator: function() { return historyMock('/foo'); }}; 385 | routerMixin.state = {route: newRoute}; 386 | routerMixin.componentDidMount(); 387 | routerMixin.componentDidUpdate({}, {route: oldRoute}); 388 | expect(testResult.replaceState).to.eql({state: {params: {}, scroll: {x: 42, y: 3}}, title: null, url: '/bar'}); 389 | }); 390 | }); 391 | 392 | }); 393 | -------------------------------------------------------------------------------- /tests/unit/utils/HistoryWithHash-test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*globals describe,it,before,beforeEach */ 6 | var HistoryWithHash = require('../../../utils/HistoryWithHash'), 7 | expect = require('chai').expect, 8 | _ = require('lodash'), 9 | windowMock, 10 | testResult; 11 | 12 | describe('HistoryWithHash', function () { 13 | 14 | beforeEach(function () { 15 | testResult = {}; 16 | windowMock = require('../../mocks/mockWindow')(testResult); 17 | }); 18 | 19 | describe('constructor', function () { 20 | it ('no useHashRoute; has pushState', function () { 21 | var history = new HistoryWithHash({win: windowMock.HTML5}); 22 | expect(history.win).to.equal(windowMock.HTML5); 23 | expect(history._hasPushState).to.equal(true); 24 | expect(history._popstateEvt).to.equal('popstate'); 25 | expect(history._useHashRoute).to.equal(false); 26 | }); 27 | it ('no useHashRoute; no pushState', function () { 28 | var history = new HistoryWithHash({win: windowMock.OLD}); 29 | expect(history.win).to.equal(windowMock.OLD); 30 | expect(history._hasPushState).to.equal(false); 31 | expect(history._popstateEvt).to.equal('hashchange'); 32 | expect(history._useHashRoute).to.equal(true); 33 | }); 34 | it ('useHashRoute=true; has pushState', function () { 35 | var history = new HistoryWithHash({win: windowMock.HTML5, useHashRoute: true}); 36 | expect(history.win).to.equal(windowMock.HTML5); 37 | expect(history._hasPushState).to.equal(true); 38 | expect(history._useHashRoute).to.equal(true); 39 | }); 40 | it ('useHashRoute=false; no pushState', function () { 41 | var history = new HistoryWithHash({win: windowMock.OLD, useHashRoute: false}); 42 | expect(history.win).to.equal(windowMock.OLD); 43 | expect(history._hasPushState).to.equal(false); 44 | expect(history._useHashRoute).to.equal(false); 45 | }); 46 | }); 47 | 48 | describe('on', function () { 49 | it ('has pushState', function () { 50 | var history = new HistoryWithHash({win: windowMock.HTML5}); 51 | var listener = function () {}; 52 | history.on(listener); 53 | expect(testResult.addEventListener).to.eql({evt: 'popstate', listener: listener}); 54 | }); 55 | it ('no pushState', function () { 56 | var history = new HistoryWithHash({win: windowMock.OLD}); 57 | var listener = function () {}; 58 | history.on(listener); 59 | expect(testResult.addEventListener).to.eql({evt: 'hashchange', listener: listener}); 60 | }); 61 | }); 62 | 63 | describe('off', function () { 64 | it ('has pushState', function () { 65 | var history = new HistoryWithHash({win: windowMock.HTML5}); 66 | var listener = function () {}; 67 | history.off(listener); 68 | expect(testResult.removeEventListener).to.eql({evt: 'popstate', listener: listener}); 69 | }); 70 | it ('no pushState', function () { 71 | var history = new HistoryWithHash({win: windowMock.OLD}); 72 | var listener = function () {}; 73 | history.off(listener); 74 | expect(testResult.removeEventListener).to.eql({evt: 'hashchange', listener: listener}); 75 | }); 76 | }); 77 | 78 | describe('getState', function () { 79 | it ('has pushState', function () { 80 | var history = new HistoryWithHash({win: _.merge({history: {state: {foo: 'bar'}}}, windowMock.HTML5)}); 81 | expect(history.getState()).to.eql({foo: 'bar'}); 82 | }); 83 | it ('no pushState', function () { 84 | var history = new HistoryWithHash({win: windowMock.OLD}); 85 | expect(history.getState()).to.eql(null); 86 | }); 87 | }); 88 | 89 | describe('getUrl', function () { 90 | it ('has pushState', function () { 91 | var win = _.extend({}, windowMock.HTML5, { 92 | location: { 93 | pathname: '/path/to/page', 94 | search: '', 95 | hash: '#/path/to/abc' 96 | } 97 | }); 98 | var history = new HistoryWithHash({win: win}); 99 | var url = history.getUrl(); 100 | expect(url).to.equal('/path/to/page'); 101 | }); 102 | it ('has pushState with query', function () { 103 | var win = _.extend({}, windowMock.HTML5, { 104 | location: { 105 | pathname: '/path/to/page', 106 | search: '?foo=bar&x=y' 107 | } 108 | }); 109 | var history = new HistoryWithHash({win: win}); 110 | var url = history.getUrl(); 111 | expect(url).to.equal('/path/to/page?foo=bar&x=y'); 112 | }); 113 | it ('no pushState', function () { 114 | var win, history, path; 115 | win = _.extend({}, windowMock.OLD, { 116 | location: { 117 | pathname: '/path/to/page', 118 | hash: '#/path/to/abc', 119 | search: '' 120 | } 121 | }); 122 | history = new HistoryWithHash({win: win}); 123 | url = history.getUrl(); 124 | expect(url).to.equal('/path/to/abc'); 125 | 126 | win = _.extend({}, windowMock.OLD, { 127 | location: { 128 | pathname: '/path/to/page', 129 | hash: '#', 130 | search: '' 131 | } 132 | }); 133 | history = new HistoryWithHash({win: win}); 134 | url = history.getUrl(); 135 | expect(url).to.equal('/', 'hash=#'); 136 | 137 | win = _.extend({}, windowMock.OLD, { 138 | location: { 139 | pathname: '/path/to/page', 140 | hash: '', 141 | search: '' 142 | } 143 | }); 144 | history = new HistoryWithHash({win: win}); 145 | url = history.getUrl(); 146 | expect(url).to.equal('/'); 147 | 148 | history = new HistoryWithHash({win: win, defaultHashRoute: '/default'}); 149 | url = history.getUrl(); 150 | expect(url).to.equal('/default'); 151 | }); 152 | it ('no pushState, with query', function () { 153 | var win, history, url; 154 | win = _.extend({}, windowMock.OLD, { 155 | location: { 156 | pathname: '/path/to/page', 157 | hash: '#/path/to/abc?foo=bar&x=y' 158 | } 159 | }); 160 | history = new HistoryWithHash({win: win}); 161 | url = history.getUrl(); 162 | expect(url).to.equal('/path/to/abc?foo=bar&x=y'); 163 | 164 | win = _.extend({}, windowMock.OLD, { 165 | location: { 166 | pathname: '/path/to/page', 167 | hash: '#/?foo=bar&x=y' 168 | } 169 | }); 170 | history = new HistoryWithHash({win: win}); 171 | url = history.getUrl(); 172 | expect(url).to.equal('/?foo=bar&x=y'); 173 | }); 174 | }); 175 | 176 | describe('pushState', function () { 177 | it ('useHashRoute=false; has pushState', function () { 178 | var win = _.extend(windowMock.HTML5, { 179 | 'document': { 180 | title: 'current title' 181 | }, 182 | location: { 183 | href: '/currentUrl' 184 | } 185 | }); 186 | var history = new HistoryWithHash({win: win}); 187 | 188 | history.pushState({foo: 'bar'}); 189 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 190 | expect(testResult.pushState.title).to.equal('current title'); 191 | expect(testResult.pushState.url).to.equal('/currentUrl'); 192 | 193 | history.pushState({foo: 'bar'}, 't', '/url'); 194 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 195 | expect(testResult.pushState.title).to.equal('t'); 196 | expect(testResult.pushState.url).to.equal('/url'); 197 | expect(windowMock.HTML5.document.title).to.equal('t'); 198 | 199 | history.pushState({foo: 'bar'}, 'tt', '/url?a=b&x=y'); 200 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 201 | expect(testResult.pushState.title).to.equal('tt'); 202 | expect(testResult.pushState.url).to.equal('/url?a=b&x=y'); 203 | expect(windowMock.HTML5.document.title).to.equal('tt'); 204 | }); 205 | it ('useHashRoute=false; has pushState; Firefox', function () { 206 | var win = _.extend(windowMock.Firefox, { 207 | 'document': { 208 | title: 'current title' 209 | }, 210 | location: { 211 | href: '/currentUrl' 212 | } 213 | }); 214 | var history = new HistoryWithHash({win: win}); 215 | 216 | history.pushState({foo: 'bar'}); 217 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 218 | expect(testResult.pushState.title).to.equal('current title'); 219 | expect(testResult.pushState.url).to.equal('/currentUrl'); 220 | 221 | history.pushState({foo: 'bar'}, 't', '/url'); 222 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 223 | expect(testResult.pushState.title).to.equal('t'); 224 | expect(testResult.pushState.url).to.equal('/url'); 225 | }); 226 | it ('useHashRoute=false; no pushState', function () { 227 | var win = _.extend({}, windowMock.OLD, {location: {}}); 228 | var history = new HistoryWithHash({ 229 | win: win, 230 | useHashRoute: false 231 | }); 232 | 233 | history.pushState({foo: 'bar'}, 't', '/url'); 234 | expect(win.location.href).to.equal('/url'); 235 | 236 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y'); 237 | expect(win.location.href).to.equal('/url?a=b&x=y'); 238 | }); 239 | it ('useHashRoute=true; has pushState', function () { 240 | var win = _.extend({}, windowMock.HTML5, { 241 | location: { 242 | pathname: '/path', 243 | search: '?a=b' 244 | } 245 | }); 246 | var history = new HistoryWithHash({win: win, useHashRoute: true}); 247 | 248 | history.pushState({foo: 'bar'}, 't', '/url'); 249 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 250 | expect(testResult.pushState.title).to.equal('t'); 251 | expect(testResult.pushState.url).to.equal('/path?a=b#/url'); 252 | 253 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y'); 254 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 255 | expect(testResult.pushState.title).to.equal('t'); 256 | expect(testResult.pushState.url).to.equal('/path?a=b#/url?a=b&x=y'); 257 | }); 258 | it ('useHashRoute=true; has pushState; has hashRouteTransformer', function () { 259 | var win = _.extend({}, windowMock.HTML5, { 260 | location: { 261 | pathname: '/path', 262 | search: '?a=b' 263 | } 264 | }); 265 | var history = new HistoryWithHash({ 266 | win: win, 267 | useHashRoute: true, 268 | hashRouteTransformer: { 269 | transform: function (hash) { 270 | return hash.replace(/\//g, '-'); 271 | } 272 | } 273 | }); 274 | 275 | history.pushState({foo: 'bar'}, 't', '/url'); 276 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 277 | expect(testResult.pushState.title).to.equal('t'); 278 | expect(testResult.pushState.url).to.equal('/path?a=b#-url'); 279 | 280 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y'); 281 | expect(testResult.pushState.state).to.eql({foo: 'bar'}); 282 | expect(testResult.pushState.title).to.equal('t'); 283 | expect(testResult.pushState.url).to.equal('/path?a=b#-url?a=b&x=y'); 284 | }); 285 | it ('useHashRoute=true; no pushState', function () { 286 | var win = _.extend({}, windowMock.OLD, { 287 | location: {} 288 | }); 289 | var history = new HistoryWithHash({win: win, useHashRoute: true}); 290 | 291 | history.pushState({foo: 'bar'}, 't', '/url'); 292 | expect(win.location.hash).to.equal('#/url'); 293 | 294 | history.pushState({foo: 'bar'}, 't', '/url?a=b&x=y'); 295 | expect(win.location.hash).to.equal('#/url?a=b&x=y'); 296 | }); 297 | }); 298 | 299 | describe('replaceState', function () { 300 | it ('useHashRouter=false; has pushState', function () { 301 | // var history = new HistoryWithHash({win: windowMock.HTML5}); 302 | var win = _.extend(windowMock.HTML5, { 303 | 'document': { 304 | title: 'current title' 305 | }, 306 | location: { 307 | href: '/currentUrl' 308 | } 309 | }); 310 | var history = new HistoryWithHash({win: win}); 311 | 312 | history.replaceState({foo: 'bar'}); 313 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 314 | expect(testResult.replaceState.title).to.equal('current title'); 315 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 316 | 317 | history.replaceState({foo: 'bar'}, 't', '/url'); 318 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 319 | expect(testResult.replaceState.title).to.equal('t'); 320 | expect(testResult.replaceState.url).to.equal('/url'); 321 | expect(windowMock.HTML5.document.title).to.equal('t'); 322 | 323 | history.replaceState({foo: 'bar'}, 'tt', '/url?a=b&x=y'); 324 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 325 | expect(testResult.replaceState.title).to.equal('tt'); 326 | expect(testResult.replaceState.url).to.equal('/url?a=b&x=y', 'url has query'); 327 | expect(windowMock.HTML5.document.title).to.equal('tt'); 328 | }); 329 | it ('useHashRouter=false; has pushState; Firefox', function () { 330 | var win = _.extend(windowMock.Firefox, { 331 | 'document': { 332 | title: 'current title' 333 | }, 334 | location: { 335 | href: '/currentUrl' 336 | } 337 | }); 338 | var history = new HistoryWithHash({win: win}); 339 | 340 | history.replaceState({foo: 'bar'}); 341 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 342 | expect(testResult.replaceState.title).to.equal('current title'); 343 | expect(testResult.replaceState.url).to.equal('/currentUrl'); 344 | 345 | history.replaceState({foo: 'bar'}, 't', '/url'); 346 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 347 | expect(testResult.replaceState.title).to.equal('t'); 348 | expect(testResult.replaceState.url).to.equal('/url'); 349 | }); 350 | it ('useHashRouter=false; no pushState', function () { 351 | var win = _.extend({}, windowMock.OLD, { 352 | location: { 353 | pathname: '/path', 354 | search: '?foo=bar', 355 | replace: function(url) { 356 | testResult.locationReplace = {url: url}; 357 | } 358 | } 359 | }); 360 | var history = new HistoryWithHash({ 361 | win: win, 362 | useHashRoute: false 363 | }); 364 | history.replaceState({foo: 'bar'}, 't', '/url'); 365 | expect(testResult.locationReplace.url).to.equal('/url'); 366 | 367 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y'); 368 | expect(testResult.locationReplace.url).to.equal('/url?a=b&x=y'); 369 | 370 | testResult.locationReplace.url = null; 371 | history.replaceState({foo: 'bar'}); 372 | expect(testResult.locationReplace.url).to.equal(null); 373 | }); 374 | it ('useHashRouter=true; has pushState', function () { 375 | var win = _.extend({}, windowMock.HTML5, { 376 | location: { 377 | pathname: '/path', 378 | search: '?foo=bar' 379 | } 380 | }); 381 | var history = new HistoryWithHash({win: win, useHashRoute: true}); 382 | history.replaceState({foo: 'bar'}, 't', '/url'); 383 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 384 | expect(testResult.replaceState.title).to.equal('t'); 385 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#/url'); 386 | 387 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y'); 388 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 389 | expect(testResult.replaceState.title).to.equal('t'); 390 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#/url?a=b&x=y', 'url has query'); 391 | 392 | }); 393 | it ('useHashRouter=true; has pushState; has hashRouteTransformer', function () { 394 | var win = _.extend({}, windowMock.HTML5, { 395 | location: { 396 | pathname: '/path', 397 | search: '?foo=bar' 398 | } 399 | }); 400 | var history = new HistoryWithHash({ 401 | win: win, 402 | useHashRoute: true, 403 | hashRouteTransformer: { 404 | transform: function (hash) { 405 | return hash.replace(/\//g, '-').replace(/\?/g, '+'); 406 | } 407 | } 408 | }); 409 | history.replaceState({foo: 'bar'}, 't', '/url'); 410 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 411 | expect(testResult.replaceState.title).to.equal('t'); 412 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#-url'); 413 | 414 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y'); 415 | expect(testResult.replaceState.state).to.eql({foo: 'bar'}); 416 | expect(testResult.replaceState.title).to.equal('t'); 417 | expect(testResult.replaceState.url).to.equal('/path?foo=bar#-url+a=b&x=y', 'url has query'); 418 | 419 | }); 420 | it ('useHashRoute=true; no pushState', function () { 421 | var win = _.extend({}, windowMock.OLD, { 422 | location: { 423 | pathname: '/path', 424 | search: '?foo=bar', 425 | replace: function(url) { 426 | testResult.locationReplace = {url: url}; 427 | } 428 | } 429 | }); 430 | var history = new HistoryWithHash({win: win, useHashRoute: true}); 431 | history.replaceState({foo: 'bar'}, 't', '/url'); 432 | expect(testResult.locationReplace.url).to.equal('/path?foo=bar#/url'); 433 | history.replaceState({foo: 'bar'}, 't', '/url?a=b&x=y'); 434 | expect(testResult.locationReplace.url).to.equal('/path?foo=bar#/url?a=b&x=y'); 435 | testResult.locationReplace.url = null; 436 | history.replaceState({foo: 'bar'}); 437 | expect(testResult.locationReplace.url).to.equal(null); 438 | }); 439 | }); 440 | 441 | }); 442 | -------------------------------------------------------------------------------- /utils/HistoryWithHash.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | /*global window */ 6 | 'use strict'; 7 | 8 | function isUndefined(v) { 9 | return v === undefined; 10 | } 11 | 12 | /** 13 | * @class HistoryWithHash 14 | * @constructor 15 | * @param {Object} [options] The options object 16 | * @param {Window} [options.win=window] The window object 17 | * @param {Boolean} [options.useHashRoute] Whether to use hash for routing url. 18 | * If nothing specified, it will be evaluated as true if pushState feature 19 | * is not available in the window object's history object; false otherwise. 20 | * @param {String} [options.defaultHashRoute='/'] Only used when options.useHashRoute is enabled and 21 | the location url does not have any hash fragment. 22 | * @param {Object} [options.hashRouteTransformer] A custom transformer can be provided 23 | * to transform the hash to the desired syntax. 24 | * @param {Function} [options.hashRouteTransformer.transform] transforms hash route string 25 | * to custom syntax to be used in setting browser history state or url. 26 | * E.g. transforms from '/about/this/path' to 'about-this-path' 27 | * @param {Function} [options.hashRouteTransformer.reverse] reverse-transforms 28 | * hash route string of custom syntax back to standard url syntax. 29 | * E.g. transforms 'about-this-path' back to '/about/this/path' 30 | */ 31 | function HistoryWithHash(options) { 32 | options = options || {}; 33 | this.win = options.win || window; 34 | 35 | this._hasPushState = !!(this.win && this.win.history && this.win.history.pushState); 36 | this._popstateEvt = this._hasPushState ? 'popstate' : 'hashchange'; 37 | 38 | // check whether to use hash for routing 39 | if (typeof options.useHashRoute === 'boolean') { 40 | this._useHashRoute = options.useHashRoute; 41 | } else { 42 | // default behavior is to check whether browser has pushState support 43 | this._useHashRoute = !this._hasPushState; 44 | } 45 | this._defaultHashRoute = options.defaultHashRoute || '/'; 46 | 47 | // allow custom syntax for hash 48 | if (options.hashRouteTransformer) { 49 | this._hashRouteTransformer = options.hashRouteTransformer; 50 | } 51 | } 52 | 53 | HistoryWithHash.prototype = { 54 | /** 55 | * Add the given listener for 'popstate' event (fall backs to 'hashchange' event 56 | * for browsers don't support popstate event). 57 | * @method on 58 | * @param {Function} listener 59 | */ 60 | on: function (listener) { 61 | this.win.addEventListener(this._popstateEvt, listener); 62 | }, 63 | 64 | /** 65 | * Remove the given listener for 'popstate' event (fall backs to 'hashchange' event 66 | * for browsers don't support popstate event). 67 | * @method off 68 | * @param {Function} listener 69 | */ 70 | off: function (listener) { 71 | this.win.removeEventListener(this._popstateEvt, listener); 72 | }, 73 | 74 | /** 75 | * Returns the hash fragment in current window location. 76 | * @method _getHashRoute 77 | * @return {String} The hash fragment string (without the # prefix). 78 | * @private 79 | */ 80 | _getHashRoute: function () { 81 | var hash = this.win.location.hash, 82 | transformer = this._hashRouteTransformer; 83 | 84 | // remove the '#' prefix 85 | hash = hash.substring(1) || this._defaultHashRoute; 86 | 87 | return (transformer && transformer.reverse) ? transformer.reverse(hash) : hash; 88 | }, 89 | 90 | /** 91 | * @method getState 92 | * @return {Object|null} The state object in history 93 | */ 94 | getState: function () { 95 | return (this.win.history && this.win.history.state) || null; 96 | }, 97 | 98 | /** 99 | * Gets the path string (or hash fragment if the history object is 100 | * configured to use hash for routing), 101 | * including the pathname and search query (if it exists). 102 | * @method getUrl 103 | * @return {String} The url string that denotes current path and query 104 | */ 105 | getUrl: function () { 106 | var location = this.win.location, 107 | path = location.pathname + location.search; 108 | 109 | if (this._useHashRoute) { 110 | return this._getHashRoute(); 111 | } 112 | return path; 113 | }, 114 | 115 | /** 116 | * Same as HTML5 pushState API, but with old browser support 117 | * @method pushState 118 | * @param {Object} state The state object 119 | * @param {String} title The title string 120 | * @param {String} url The new url 121 | */ 122 | pushState: function (state, title, url) { 123 | var win = this.win, 124 | history = win.history, 125 | location = win.location, 126 | hash, 127 | transformer = this._hashRouteTransformer; 128 | 129 | if (this._useHashRoute) { 130 | hash = (transformer && transformer.transform) ? transformer.transform(url) : url; 131 | if (hash) { 132 | hash = '#' + hash; 133 | } 134 | if (this._hasPushState) { 135 | url = hash ? location.pathname + location.search + hash : null; 136 | history.pushState(state, title, url); 137 | this.setTitle(title); 138 | } else if (hash) { 139 | location.hash = hash; 140 | } 141 | } else { 142 | if (this._hasPushState) { 143 | title = isUndefined(title) ? win.document.title : title; 144 | url = isUndefined(url) ? win.location.href : url; 145 | history.pushState(state, title, url); 146 | this.setTitle(title); 147 | } else if (url) { 148 | location.href = url; 149 | } 150 | } 151 | }, 152 | 153 | /** 154 | * Same as HTML5 replaceState API, but with old browser support 155 | * @method replaceState 156 | * @param {Object} state The state object 157 | * @param {String} title The title string 158 | * @param {String} url The new url 159 | */ 160 | replaceState: function (state, title, url) { 161 | var win = this.win, 162 | history = win.history, 163 | location = win.location, 164 | hash, 165 | transformer = this._hashRouteTransformer; 166 | 167 | if (this._useHashRoute) { 168 | hash = (transformer && transformer.transform) ? transformer.transform(url) : url; 169 | if (hash) { 170 | hash = '#' + hash; 171 | } 172 | if (this._hasPushState) { 173 | url = hash ? (location.pathname + location.search + hash) : null; 174 | history.replaceState(state, title, url); 175 | this.setTitle(title); 176 | } else if (url) { 177 | url = location.pathname + location.search + hash; 178 | location.replace(url); 179 | } 180 | } else { 181 | if (this._hasPushState) { 182 | title = isUndefined(title) ? win.document.title : title; 183 | url = isUndefined(url) ? win.location.href : url; 184 | history.replaceState(state, title, url); 185 | this.setTitle(title); 186 | } else if (url) { 187 | location.replace(url); 188 | } 189 | } 190 | }, 191 | 192 | /** 193 | * Sets document title. No-op if title is empty. 194 | * @param {String} title The title string. 195 | */ 196 | setTitle: function (title) { 197 | if (title) { 198 | this.win.document.title = title; 199 | } 200 | } 201 | }; 202 | 203 | module.exports = HistoryWithHash; 204 | -------------------------------------------------------------------------------- /utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2014, Yahoo! Inc. 3 | * Copyrights licensed under the New BSD License. See the accompanying LICENSE file for terms. 4 | */ 5 | 'use strict'; 6 | 7 | module.exports = { 8 | HistoryWithHash: require('./HistoryWithHash') 9 | }; 10 | --------------------------------------------------------------------------------