├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── .watchmanconfig ├── LICENSE.md ├── README.md ├── lib ├── attachHistoryModifiers.js ├── createOrchestrator.js ├── index.js └── navigation.js ├── package.json └── src ├── attachHistoryModifiers.js ├── createOrchestrator.js ├── index.js └── navigation.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"], 3 | "plugins": ["add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain 2 | # consistent coding styles between different editors and IDEs. 3 | 4 | root = true 5 | 6 | [*] 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | 4 | "parser": "babel-eslint", 5 | 6 | "plugins": [ 7 | "babel" 8 | ], 9 | 10 | "rules": { 11 | "semi": [2, "never"], 12 | "no-trailing-spaces": [2, { "skipBlankLines": true }], 13 | "default-case": 0, 14 | "no-unused-expressions": [2, { 15 | "allowShortCircuit": true, 16 | "allowTernary": true 17 | }], 18 | "no-shadow": 0, 19 | "new-cap": 0, 20 | "no-loop-func": 0, 21 | "prefer-template": 0, 22 | "no-param-reassign": 0, 23 | "func-names": 0, 24 | "consistent-return": 0, 25 | "no-underscore-dangle": 0, 26 | "import/no-unresolved": 0, 27 | 28 | "react/jsx-closing-bracket-location": [1, "after-props"], 29 | }, 30 | 31 | "ecmaFeatures": { 32 | "experimentalObjectRestSpread": true 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | lib 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": [ 3 | ".git", 4 | "node_modules" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React stack nav 2 | ========== 3 | Simple universal navigation for React Native and React 4 | 5 | - **Universal** Works in iOS, Android, and Web 6 | - **Familiar API** Has the same API as the web's [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) 7 | - **Back/forward button support** Automatic support for Android back button and back/forward buttons on web 8 | - **Deep linking support** Automatic support for deep linking 9 | - **Server-side rendering** Simple as pre-loading redux state with the requested url 10 | - **Composable and declarative** Uses React's component tree to compose and handle routes 11 | - **Use any navigation paradigm** Abstract enough to let you build the navigation with any UI components you want 12 | - **Easy to understand** You can read the source! Only ~300 lines of code 13 | 14 | ### Examples 15 | 16 | iOS | Android | Web 17 | :---:|:---:|:---: 18 | | **[Drawer example](https://github.com/tuckerconnelly/react-stack-nav-examples/tree/master/Drawer)** | 19 | ![iOS drawer Example](https://cloud.githubusercontent.com/assets/4349082/20870986/9dd28bc6-ba5e-11e6-9dfb-0f22e334f95e.gif) | ![Android drawer example](https://cloud.githubusercontent.com/assets/4349082/20870990/a194c5ee-ba5e-11e6-923b-7a82d0d1e3d3.gif) | ![web drawer example](https://cloud.githubusercontent.com/assets/4349082/20870981/9a86f06a-ba5e-11e6-8762-6220955ce10a.gif) 20 | | **[Bottom nav with stacks example](https://github.com/tuckerconnelly/react-stack-nav-examples/tree/master/BottomNavWithStacks)** | 21 | ![iOS bottom nav Example](https://cloud.githubusercontent.com/assets/4349082/20981756/dc85dac2-bc83-11e6-85d6-b2733e16b8dd.gif) | ![Android Bottom nav example](https://cloud.githubusercontent.com/assets/4349082/20982072/0a2000ce-bc85-11e6-9b6a-9cd56f3d2bb6.gif) | ![web bottom nav example](https://cloud.githubusercontent.com/assets/4349082/20981759/e114fc76-bc83-11e6-8165-85202f231fbf.gif) 22 | 23 | The examples repo is [over here](https://github.com/tuckerconnelly/react-stack-nav-examples). 24 | 25 | You can also check out a real-world example in the [Carbon UI Docs source](https://github.com/tuckerconnelly/carbon-ui-docs). 26 | 27 | ### What it looks like 28 | 29 | A navigation component (drawer, tabs, anything): 30 | ```js 31 | import React from 'react' 32 | import { Button, View } from 'react-native' 33 | import { connect } from 'react-redux' 34 | import { pushState } from 'react-stack-nav' 35 | 36 | const Navigation = ({ pushState }) => 37 | 38 | 39 | 40 | 41 | 42 | const mapDispatchToProps = { pushState } 43 | 44 | export default connect(null, mapDispatchToProps)(Navigation) 45 | ``` 46 | 47 | A component receives and handles the first fragment of the url: 48 | ```js 49 | import React from 'react' 50 | import { Text, View } from 'react-native' 51 | import { createOrchestrator } from 'react-stack-nav' 52 | 53 | const Index = ({ routeFragment }) => 54 | 55 | {routeFragment === '' && Home} 56 | {routeFragment === 'myRoute' && My route} 57 | 58 | 59 | export default createOrchestrator()(Index) 60 | ``` 61 | 62 | ### Installation 63 | 64 | ``` 65 | npm -S i react-stack-nav 66 | ``` 67 | 68 | Then match your redux setup to this: 69 | 70 | ```js 71 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux' 72 | import thunk from 'redux-thunk' 73 | import { navigation, attachHistoryModifiers } from 'react-stack-nav' 74 | import { BackAndroid, Linking } from 'react-native' 75 | 76 | import app from './modules/duck' 77 | 78 | const rootReducer = combineReducers({ navigation }) 79 | 80 | export default (initialState = {}) => 81 | createStore( 82 | rootReducer, 83 | initialState, 84 | compose( 85 | applyMiddleware(thunk), 86 | attachHistoryModifiers({ BackAndroid, Linking }), 87 | ), 88 | ) 89 | ``` 90 | 91 | If you want deep linking to work, make sure you set it up per instructions [here](https://facebook.github.io/react-native/docs/linking.html). 92 | 93 | ### Principles 94 | 95 | - Treat the redux store as a single-source-of-truth for routing 96 | - Use URLs and the History API, even in React Native 97 | - Treat the URL as a stack, and "pop" off fragments of the URL to orchestrate transitions between URLs 98 | - Leave you in control, favor patterns over frameworks 99 | 100 | ### Usage 101 | 102 | At the core of react-stack-nav is the `navigation` reducer, whose state looks like this: 103 | 104 | ```js 105 | { 106 | index: 0, // The index of the current history object 107 | history: [{ statObj, title, url }], // An ordered list of history objects from pushState 108 | } 109 | ``` 110 | 111 | If you want to navigate to a new url/page, use the `pushState` action creator in one of your components (it's the same as [history.pushState](https://developer.mozilla.org/en-US/docs/Web/API/History_API)!): 112 | 113 | ```js 114 | import { pushState } from 'react-stack-nav' 115 | 116 | class MyComponent { 117 | _handleClick = () => this.props.pushState({ yo: 'dawg' }, 'New Route', '/newRoute') 118 | } 119 | ``` 120 | 121 | That'll push a new object onto `state.navigation.history`, so your new navigation reducer state might look like this: 122 | 123 | ```js 124 | { 125 | index: 1, 126 | history: [ 127 | { stateObj: {}, title: '', url: '' }, 128 | { stateObj: {}, title: 'New Route', '/newRoute' } 129 | ] 130 | } 131 | ``` 132 | 133 | Now say you clicked back in the browser (or hit the android back button), your state would automatically update to: 134 | 135 | ```js 136 | { 137 | index: 0, 138 | history: [/* same as above */] 139 | } 140 | ``` 141 | 142 | With `index: 0` to say, hey, we're on the first history entry. 143 | 144 | --- 145 | 146 | If you want to use the history object, to ya know, render stuff, you can do so directly: 147 | 148 | ```js 149 | 150 | const MyComponent = ({ url, title }) => 151 | 152 | {title} 153 | {url === '/users' && } 154 | {url === '/pictures' && } 155 | 156 | 157 | const mapStateToProps = ({ navigation }) => ({ 158 | url: navigation.history[navigation.index].url, 159 | title: navigation.history[navigation.index].url, 160 | }) 161 | 162 | connect(mapStateToProps)(MyComponent) 163 | ``` 164 | 165 | You could handle all your routing like that, but...wait a minute...if you turn a url sideways, it kinda looks like a stack yeah? 166 | 167 | ``` 168 | /users/1/profile 169 | 170 | users 171 | ------- 172 | 1 173 | ------- 174 | profile 175 | ``` 176 | 177 | `react-stack-nav` runs with this idea and introduces the idea of an _orchestrator_. Orchestrators pops off an element in the stack and declaratively handles transitions when it changes: 178 | 179 | ``` 180 | users -------> Popped off and handled by first orchestrator in component tree 181 | ------- 182 | 1 -------> Popped off and handled by second orchestrator in component tree 183 | ------- 184 | profile -------> Popped off and handled by third orchestrator in component tree 185 | ``` 186 | 187 | The first orchestrator found in the component tree would handle changes to the top of the stack: 188 | 189 | 190 | ```js 191 | import { createOrchestrator } from 'react-stack-nav' 192 | 193 | const Index = ({ routeFragment }) => { 194 | 195 | {routeFragment === 'users' && } 196 | {routeFragment === 'pictures' && } 197 | 198 | } 199 | 200 | export default createOrchestrator()(Index) 201 | ``` 202 | 203 | The next orchestrators found in the tree would handle the next layer of the url stack, so `UsersIndex` might look like this: 204 | 205 | ```js 206 | class UsersIndex extends Component { 207 | async componentWillReceiveProps(next) { 208 | const { routeFragment } = this.props 209 | if (routeFragment !== next.routeFragment) return 210 | 211 | this.setState({ user: await fetchUserData(routeFragment) }) 212 | } 213 | 214 | render() { 215 | 216 | {/* Use this.state.user info */} 217 | 218 | } 219 | } 220 | 221 | export default createOrchestrator('users')(UsersIndex) 222 | ``` 223 | 224 | `createOrchestrator()` accepts a string or a regular expression for that particular layer of that stack. If it doesn't match the current layer of the url, `props.routeFragment` will be undefined. 225 | 226 | You could create orchestrators _ad inifitum_ all the way down the component tree to handle as much of the URL stack as you want: 227 | 228 | ```js 229 | const UserIndex = ({ routeFragment }) => { 230 | 231 | {routeFragment === 'profile' && } 232 | {routeFragment === 'settings' && } 233 | 234 | } 235 | 236 | export default createOrchestrator(/\d+/)(UserIndex) 237 | ``` 238 | 239 | ```js 240 | const ProfileIndex = ({ routeFragment }) => { 241 | 242 | {/* Would handle /users/${userId}/profile/posts */} 243 | {routeFragment === 'posts' && } 244 | {/* Would handle /users/${userId}/profile/pictures */} 245 | {routeFragment === 'pictures' && } 246 | 247 | } 248 | 249 | export default createOrchestrator('profile')(ProfileIndex) 250 | 251 | ``` 252 | 253 | ### API 254 | 255 | - `attachHistoryModifiers` - Store enhancer for redux, handles android back-button presses, browser back/forward buttons, and deep linking 256 | - `createOrchestrator(fragment: string | regexp)` -- Higher-order-component that creates an orchestrator 257 | - `navigation` -- `navigation` reducer for setting up your store 258 | 259 | #### Action creators 260 | 261 | These are the same as the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) : 262 | 263 | - `pushState(stateObj: object, title: string, url: string)`: Pushes a new state onto the history array 264 | - `replaceState(stateObj: object, title: string, url: string)`: Replaces the current state on the history array 265 | - `back(reduxOnly: bool)`: Moves the `navigation.index` back one. If `reduxOnly` is true, it won't change the browser `history` state (this is mostly for internal use) 266 | - `forward(reduxOnly: bool)`: Moves the `navigation.index` forward one, reduxOnly is the same as for `back()` 267 | - `go(numberOfEntries: int)`: Moves the `navigation.index` forward/backward by the passed number (`go(1)` is the same as `forward()`, for example). 268 | - `replaceTop(stateObj: object, title: string, toUrl: string)`: Like `replaceState`, but appends the `toUrl` to the current url instead of replacing it outright. Primarily used for index redirects. 269 | - `pushTop(stateObj: object, title: string, toUrl: string)`: Like `pushState`, but appends the `toUrl` to the current url instead of replacing it outright. Primarily used for card stacks. 270 | 271 | ### Connect 272 | 273 | Follow the creator on Twitter, [@TuckerConnelly](https://twitter.com/TuckerConnelly) 274 | 275 | ### License 276 | MIT 277 | -------------------------------------------------------------------------------- /lib/attachHistoryModifiers.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports,"__esModule",{value:true});exports.default= 2 | 3 | 4 | 5 | 6 | 7 | 8 | attachHistoryModifiers;var _urlParse=require('url-parse');var _urlParse2=_interopRequireDefault(_urlParse);var _navigation=require('./navigation');function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj};} // HACK global.__BUNDLE_START_TIME__ is only present in React Native 9 | var __WEB__=!global.__BUNDLE_START_TIME__&&window.location.pathname;function attachHistoryModifiers(){var _ref=arguments.length<=0||arguments[0]===undefined?{}:arguments[0];var BackAndroid=_ref.BackAndroid;var Linking=_ref.Linking;return function(createStore){return function(reducer,preloadedState,enhancer){ 10 | var store=createStore(reducer,preloadedState,enhancer);var 11 | dispatch=store.dispatch;var getState=store.getState; 12 | 13 | if(__WEB__){ 14 | window.onpopstate=function(_ref2){var state=_ref2.state; 15 | var newIndex=getState().navigation.history. 16 | map(function(s){return s.stateObj.index;}). 17 | indexOf(state.index); 18 | var lastIndex=getState().navigation.index; 19 | 20 | if(newIndex<=lastIndex)dispatch((0,_navigation.back)(true)); 21 | if(newIndex>lastIndex)dispatch((0,_navigation.forward)(true));};} 22 | 23 | 24 | if(BackAndroid){ 25 | BackAndroid.addEventListener('hardwareBackPress',function(){var 26 | index=store.getState().navigation.index; 27 | if(index===0)return false; 28 | dispatch((0,_navigation.back)()); 29 | return true;});} 30 | 31 | 32 | if(Linking){ 33 | Linking.addEventListener('url',function(_ref3){var url=_ref3.url;var _ref4= 34 | new _urlParse2.default(url);var pathname=_ref4.pathname;var query=_ref4.query;var hash=_ref4.hash; 35 | dispatch((0,_navigation.replaceState)(0,0,''+pathname+query+hash));});} 36 | 37 | 38 | 39 | return store;};};}module.exports=exports['default']; -------------------------------------------------------------------------------- /lib/createOrchestrator.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports,"__esModule",{value:true});var _extends=Object.assign||function(target){for(var i=1;i at the top '+'of your app.');(0,_invariant2.default)(context.store.getState().navigation,'Couldn\'t find the navigation reducer on the store. '+'Make sure you have react-stack-nav\'s reducer on '+'your root reducer.');return _this2;}_createClass(Orchestrator,[{key:'getChildContext',value:function getChildContext(){return {lastOrchestratorId:this._orchestratorId,orchestratorPath:this._orchestratorPath};}},{key:'componentWillMount',value:function componentWillMount(){var lastOrchestratorId=this.context.lastOrchestratorId;this._orchestratorId=lastOrchestratorId!==undefined?lastOrchestratorId+1:0;this._orchestratorPath=this.context.orchestratorPath?[].concat(_toConsumableArray(this.context.orchestratorPath),[fragment]):[];this._updateUrlStack();this._unsubscribeFromStore=this.context.store.subscribe(this._updateUrlStack);}},{key:'componentWillUnmount',value:function componentWillUnmount(){this._unsubscribeFromStore();}},{key:'render',value:function render() 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | { 95 | return ( 96 | _react2.default.createElement(ComposedComponent,_extends({}, 97 | this.props,{ 98 | routeFragment:this._routeFragment,__source:{fileName:_jsxFileName,lineNumber:96}})));}},{key:'_routeFragment',get:function get(){var _this3=this;var urlMatchesOrchestratorPath=this._orchestratorPath.reduce(function(prev,curr,i){if(!prev)return false;if(!_this3.state.urlStack[i])return false; // Handle regex orchestrators 99 | if(_this3._orchestratorPath[i] instanceof RegExp){return _this3.state.urlStack[i].match(_this3._orchestratorPath[i]);} // Handle string orchestrators 100 | return _this3.state.urlStack[i]===_this3._orchestratorPath[i];},true);if(!urlMatchesOrchestratorPath)return undefined;return this.state.urlStack[this._orchestratorId]||'';}}]);return Orchestrator;}(_react.Component); 101 | 102 | 103 | Orchestrator.displayName='Orchestrator('+( 104 | _react.Component.displayName||_react.Component.name||'Component')+')'; 105 | 106 | Orchestrator.contextTypes=_extends({}, 107 | Orchestrator.contextTypes,{ 108 | store:_react.PropTypes.object, 109 | lastOrchestratorId:_react.PropTypes.number, 110 | orchestratorPath:_react.PropTypes.array}); 111 | 112 | 113 | Orchestrator.childContextTypes=_extends({}, 114 | Orchestrator.childContextTypes,{ 115 | lastOrchestratorId:_react.PropTypes.number, 116 | orchestratorPath:_react.PropTypes.array}); 117 | 118 | 119 | return Orchestrator;};};module.exports=exports['default']; -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports,"__esModule",{value:true});var _attachHistoryModifiers=require('./attachHistoryModifiers');Object.defineProperty(exports,'attachHistoryModifiers',{enumerable:true,get:function get(){return _interopRequireDefault(_attachHistoryModifiers).default;}});var _createOrchestrator=require('./createOrchestrator');Object.defineProperty(exports,'createOrchestrator',{enumerable:true,get:function get(){return _interopRequireDefault(_createOrchestrator). 2 | default;}});var _navigation=require('./navigation'); 3 | Object.keys(_navigation).forEach(function(key){if(key==="default")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _navigation[key];}});});Object.defineProperty(exports,'navigation',{enumerable:true,get:function get(){return _interopRequireDefault(_navigation). 4 | default;}});function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj};} -------------------------------------------------------------------------------- /lib/navigation.js: -------------------------------------------------------------------------------- 1 | Object.defineProperty(exports,"__esModule",{value:true});var _extends=Object.assign||function(target){for(var i=1;i", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "babel-cli": "^6.14.0", 28 | "babel-core": "^6.14.0", 29 | "babel-eslint": "^6.1.2", 30 | "babel-plugin-add-module-exports": "^0.2.1", 31 | "babel-preset-react-native": "^1.9.0", 32 | "eslint": "^3.4.0", 33 | "eslint-config-airbnb": "^10.0.1", 34 | "eslint-plugin-babel": "^3.3.0", 35 | "eslint-plugin-import": "^1.14.0", 36 | "eslint-plugin-jsx-a11y": "^2.2.1", 37 | "eslint-plugin-react": "^6.2.0" 38 | }, 39 | "peerDependencies": { 40 | "react": ">=15.3.2", 41 | "redux": ">=3.0.0" 42 | }, 43 | "dependencies": { 44 | "url-parse": "^1.1.7" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/attachHistoryModifiers.js: -------------------------------------------------------------------------------- 1 | import URL from 'url-parse' 2 | 3 | // HACK global.__BUNDLE_START_TIME__ is only present in React Native 4 | const __WEB__ = !global.__BUNDLE_START_TIME__ && window.location.pathname 5 | 6 | import { replaceState, back, forward } from './navigation' 7 | 8 | export default function attachHistoryModifiers({ BackAndroid, Linking } = {}) { 9 | return createStore => (reducer, preloadedState, enhancer) => { 10 | const store = createStore(reducer, preloadedState, enhancer) 11 | const { dispatch, getState } = store 12 | 13 | if (__WEB__) { 14 | window.onpopstate = ({ state }) => { 15 | const newIndex = getState().navigation.history 16 | .map(s => s.stateObj.index) 17 | .indexOf(state.index) 18 | const lastIndex = getState().navigation.index 19 | 20 | if (newIndex <= lastIndex) dispatch(back(true)) 21 | if (newIndex > lastIndex) dispatch(forward(true)) 22 | } 23 | } 24 | if (BackAndroid) { 25 | BackAndroid.addEventListener('hardwareBackPress', () => { 26 | const { index } = store.getState().navigation 27 | if (index === 0) return false 28 | dispatch(back()) 29 | return true 30 | }) 31 | } 32 | if (Linking) { 33 | Linking.addEventListener('url', ({ url }) => { 34 | const { pathname, query, hash } = new URL(url) 35 | dispatch(replaceState(0, 0, `${pathname}${query}${hash}`)) 36 | }) 37 | } 38 | 39 | return store 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/createOrchestrator.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import invariant from 'invariant' 3 | 4 | function makeStackFromPathname(pathname) { 5 | const pathArray = pathname.split('/') 6 | pathArray.shift() // Remove first blank "" 7 | return pathArray 8 | } 9 | 10 | export default fragment => component => { 11 | let ComposedComponent = component 12 | 13 | // Handle stateless components 14 | if (!ComposedComponent.render && !ComposedComponent.prototype.render) { 15 | ComposedComponent = class extends Component { 16 | render() { 17 | return component(this.props, this.context) 18 | } 19 | } 20 | } 21 | 22 | class Orchestrator extends Component { // eslint-disable-line react/no-multi-comp 23 | constructor(props, context) { 24 | super(props, context) 25 | invariant(context.store, 26 | 'Couldn\'t find the store on the context. ' + 27 | 'Make sure you have a redux at the top ' + 28 | 'of your app.') 29 | 30 | invariant(context.store.getState().navigation, 31 | 'Couldn\'t find the navigation reducer on the store. ' + 32 | 'Make sure you have react-stack-nav\'s reducer on ' + 33 | 'your root reducer.' 34 | ) 35 | } 36 | 37 | state = { urlStack: [] } 38 | 39 | getChildContext() { 40 | return { 41 | lastOrchestratorId: this._orchestratorId, 42 | orchestratorPath: this._orchestratorPath, 43 | } 44 | } 45 | 46 | componentWillMount() { 47 | const { lastOrchestratorId } = this.context 48 | 49 | this._orchestratorId = lastOrchestratorId !== undefined ? 50 | lastOrchestratorId + 1 : 51 | 0 52 | this._orchestratorPath = this.context.orchestratorPath ? 53 | [...this.context.orchestratorPath, fragment] : 54 | [] 55 | 56 | this._updateUrlStack() 57 | this._unsubscribeFromStore = this.context.store.subscribe(this._updateUrlStack) 58 | } 59 | 60 | componentWillUnmount() { 61 | this._unsubscribeFromStore() 62 | } 63 | 64 | _unsubscribeFromStore = null 65 | 66 | _updateUrlStack = () => { 67 | const state = this.context.store.getState() 68 | const index = state.navigation.index 69 | // Default to redux store for stack if this is the first 70 | // route and the navStack hasn't been set yet 71 | this.setState({ 72 | urlStack: makeStackFromPathname(state.navigation.history[index].url), 73 | }) 74 | } 75 | 76 | get _routeFragment() { 77 | const urlMatchesOrchestratorPath = this._orchestratorPath.reduce((prev, curr, i) => { 78 | if (!prev) return false 79 | 80 | if (!this.state.urlStack[i]) return false 81 | // Handle regex orchestrators 82 | if (this._orchestratorPath[i] instanceof RegExp) { 83 | return this.state.urlStack[i].match(this._orchestratorPath[i]) 84 | } 85 | // Handle string orchestrators 86 | return this.state.urlStack[i] === this._orchestratorPath[i] 87 | }, true) 88 | 89 | if (!urlMatchesOrchestratorPath) return undefined 90 | 91 | return this.state.urlStack[this._orchestratorId] || '' 92 | } 93 | 94 | render() { 95 | return ( 96 | 99 | ) 100 | } 101 | } 102 | 103 | Orchestrator.displayName = 104 | `Orchestrator(${Component.displayName || Component.name || 'Component'})` 105 | 106 | Orchestrator.contextTypes = { 107 | ...Orchestrator.contextTypes, 108 | store: PropTypes.object, 109 | lastOrchestratorId: PropTypes.number, 110 | orchestratorPath: PropTypes.array, 111 | } 112 | 113 | Orchestrator.childContextTypes = { 114 | ...Orchestrator.childContextTypes, 115 | lastOrchestratorId: PropTypes.number, 116 | orchestratorPath: PropTypes.array, 117 | } 118 | 119 | return Orchestrator 120 | } 121 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as attachHistoryModifiers } from './attachHistoryModifiers' 2 | export { default as createOrchestrator } from './createOrchestrator' 3 | export * from './navigation' 4 | export { default as navigation } from './navigation' 5 | -------------------------------------------------------------------------------- /src/navigation.js: -------------------------------------------------------------------------------- 1 | // HACK global.__BUNDLE_START_TIME__ is only present in React Native 2 | const __WEB__ = !global.__BUNDLE_START_TIME__ && window.location.pathname 3 | 4 | const removeTrailingSlashFromUrl = url => { 5 | const urlParts = url.split('?') 6 | urlParts[0] = urlParts[0].replace(/\/$/, '') 7 | return urlParts.join('?') 8 | } 9 | 10 | export const pushState = (stateObj, title, url) => ({ 11 | type: 'HISTORY_PUSH_STATE', 12 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) }, 13 | }) 14 | export const replaceState = (stateObj, title, url) => ({ 15 | type: 'HISTORY_REPLACE_STATE', 16 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) }, 17 | }) 18 | export const back = fromPopState => { 19 | if (__WEB__ && !fromPopState) { 20 | window.history.back() 21 | return { type: 'NULL' } 22 | } 23 | return { type: 'HISTORY_BACK' } 24 | } 25 | export const forward = fromPopState => { 26 | if (__WEB__ && !fromPopState) { 27 | window.history.forward() 28 | return { type: 'NULL' } 29 | } 30 | return { type: 'HISTORY_FORWARD' } 31 | } 32 | export const go = numberOfEntries => ({ 33 | type: 'HISTORY_GO', 34 | payload: { numberOfEntries }, 35 | }) 36 | export const replaceTop = (stateObj, title, url) => ({ 37 | type: 'HISTORY_REPLACE_TOP', 38 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) }, 39 | }) 40 | export const pushTop = (stateObj, title, url) => ({ 41 | type: 'HISTORY_PUSH_TOP', 42 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) }, 43 | }) 44 | 45 | const initialState = { 46 | index: 0, 47 | history: [{ stateObj: { index: 0 }, title: '', url: '' }], 48 | } 49 | 50 | if (__WEB__) { 51 | initialState.history[0].url = removeTrailingSlashFromUrl(window.location.pathname) 52 | window.history.replaceState( 53 | initialState.history[0].stateObj, 54 | initialState.history[0].title, 55 | initialState.history[0].url 56 | ) 57 | } 58 | 59 | export default (state = initialState, action) => { 60 | switch (action.type) { 61 | case 'HISTORY_PUSH_STATE': { 62 | const { stateObj, title, url } = action.payload 63 | 64 | if (url === state.history[state.index].url) return state 65 | 66 | const stateObjWithIndex = { ...stateObj, index: state.index + 1 } 67 | 68 | if (__WEB__) window.history.pushState(stateObjWithIndex, title, url.length ? url : '/') 69 | return { 70 | index: state.index + 1, 71 | history: state.history 72 | .slice(0, state.index + 1) 73 | .concat([{ stateObj: stateObjWithIndex, title, url }]), 74 | } 75 | } 76 | case 'HISTORY_REPLACE_STATE': { 77 | const { stateObj, title, url } = action.payload 78 | 79 | if (url === state.history[state.index].url) return state 80 | 81 | const stateObjWithIndex = { ...stateObj, index: state.index } 82 | 83 | if (__WEB__) window.history.replaceState(stateObjWithIndex, title, url.length ? url : '/') 84 | return { 85 | index: state.index, 86 | history: state.history 87 | .slice(0, state.index) 88 | .concat([{ stateObj: stateObjWithIndex, title, url }]), 89 | } 90 | } 91 | case 'HISTORY_REPLACE_TOP': { 92 | const { stateObj, title, url } = action.payload 93 | 94 | const stateObjWithIndex = { ...stateObj, index: state.index } 95 | 96 | const newUrl = state.history[state.index].url + '/' + url 97 | 98 | if (__WEB__) window.history.replaceState(stateObjWithIndex, title, newUrl) 99 | return { 100 | index: state.index, 101 | history: state.history 102 | .slice(0, state.index) 103 | .concat([{ stateObj: stateObjWithIndex, title, url: newUrl }]), 104 | } 105 | } 106 | case 'HISTORY_PUSH_TOP': { 107 | const { stateObj, title, url } = action.payload 108 | 109 | const stateObjWithIndex = { ...stateObj, index: state.index + 1 } 110 | 111 | const newUrl = state.history[state.index].url + '/' + url 112 | 113 | if (__WEB__) window.history.pushState(stateObjWithIndex, title, newUrl) 114 | return { 115 | index: state.index + 1, 116 | history: state.history 117 | .slice(0, state.index + 1) 118 | .concat([{ stateObj: stateObjWithIndex, title, url: newUrl }]), 119 | } 120 | } 121 | case 'HISTORY_BACK': 122 | if (state.index === 0) return state 123 | return { ...state, index: state.index - 1 } 124 | 125 | case 'HISTORY_FORWARD': 126 | if (state.index === state.history.length - 1) return state 127 | return { ...state, index: state.index + 1 } 128 | 129 | case 'HISTORY_GO': { 130 | const targetIndex = state.index + action.payload.numberOfEntries 131 | 132 | if (!state.history[targetIndex]) { 133 | console.warn('Tried to `go()` to a non-existant index!') // eslint-disable-line no-console, max-len 134 | return state 135 | } 136 | return { ...state, index: targetIndex } 137 | } 138 | 139 | default: return state 140 | } 141 | } 142 | --------------------------------------------------------------------------------