├── .babelrc ├── .circleci └── config.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .release-it.json ├── LICENSE.md ├── README.md ├── createBrowserApp.js ├── handleServerRequest.js ├── jest-setup.js ├── package.json ├── src ├── Link.js ├── __tests__ │ ├── __snapshots__ │ │ └── createBrowserApp-test.js.snap │ └── createBrowserApp-test.js ├── createBrowserApp.js ├── handleServerRequest.js └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react-native"] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | docker: 5 | - image: circleci/node:7.10 6 | working_directory: ~/project 7 | 8 | jobs: 9 | install-dependencies: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - attach_workspace: 14 | at: ~/project 15 | - restore_cache: 16 | keys: 17 | - v1-dependencies-{{ checksum "package.json" }} 18 | - v1-dependencies- 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-example-{{ checksum "example/package.json" }} 22 | - v1-dependencies-example- 23 | - run: | 24 | yarn install 25 | yarn install --cwd example 26 | - save_cache: 27 | key: v1-dependencies-{{ checksum "package.json" }} 28 | paths: node_modules 29 | - save_cache: 30 | key: v1-dependencies-example-{{ checksum "example/package.json" }} 31 | paths: example/node_modules 32 | - persist_to_workspace: 33 | root: . 34 | paths: . 35 | lint: 36 | <<: *defaults 37 | steps: 38 | - attach_workspace: 39 | at: ~/project 40 | - run: | 41 | yarn run lint 42 | unit-tests: 43 | <<: *defaults 44 | steps: 45 | - attach_workspace: 46 | at: ~/project 47 | - run: yarn test -- --coverage 48 | - store_artifacts: 49 | path: coverage 50 | destination: coverage 51 | 52 | workflows: 53 | version: 2 54 | build-and-test: 55 | jobs: 56 | - install-dependencies 57 | - lint: 58 | requires: 59 | - install-dependencies 60 | - unit-tests: 61 | requires: 62 | - install-dependencies 63 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # we recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | jest-setup.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-satya164", 3 | 4 | "plugins": ["react-native-globals"], 5 | 6 | "env": { 7 | "es6": true, 8 | "react-native-globals/all": true, 9 | }, 10 | 11 | "rules": { 12 | "import/no-unresolved": "off", 13 | "react/sort-comp": "off", 14 | "jest/no-disabled-tests": "off", 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # XDE 6 | .expo/ 7 | 8 | # VSCode 9 | .vscode/ 10 | tsconfig.json 11 | jsconfig.json 12 | 13 | # Xcode 14 | # 15 | build/ 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | xcuserdata 25 | *.xccheckout 26 | *.moved-aside 27 | DerivedData 28 | *.hmap 29 | *.ipa 30 | *.xcuserstate 31 | project.xcworkspace 32 | 33 | # Android/IJ 34 | # 35 | .idea 36 | .gradle 37 | local.properties 38 | 39 | # node.js 40 | # 41 | node_modules/ 42 | npm-debug.log 43 | yarn-debug.log 44 | yarn-error.log 45 | 46 | # BUCK 47 | buck-out/ 48 | \.buckd/ 49 | android/app/libs 50 | android/keystores/debug.keystore 51 | 52 | # Build 53 | dist/ 54 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "increment": "conventional:angular", 3 | "changelogCommand": "conventional-changelog -p angular | tail -n +3", 4 | "safeBump": false, 5 | "src": { 6 | "commitMessage": "chore: release %s", 7 | "tagName": "v%s" 8 | }, 9 | "npm": { 10 | "publish": true 11 | }, 12 | "github": { 13 | "release": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 React Native Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Navigation Web 2 | 3 | [![npm version](https://badge.fury.io/js/%40react-navigation%2Fweb.svg)](https://badge.fury.io/js/%40react-navigation%2Fweb) [![CircleCI badge](https://circleci.com/gh/react-navigation/react-navigation-web/tree/master.svg?style=shield)](https://circleci.com/gh/react-navigation/react-navigation-web/tree/master) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://reactnavigation.org/docs/en/contributing.html) 4 | 5 | ## Docs 6 | 7 | Documentation can be found on the [React Navigation website](https://reactnavigation.org/docs/en/getting-started.html). 8 | 9 | See the [guide for usage on the web.](https://reactnavigation.org/docs/en/web-support.html) 10 | -------------------------------------------------------------------------------- /createBrowserApp.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | 3 | module.exports = require('./dist/navigators/createBrowserApp'); 4 | -------------------------------------------------------------------------------- /handleServerRequest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-commonjs */ 2 | 3 | module.exports = require('./dist/handleServerRequest'); 4 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | /** 2 | * eslint-env jest 3 | */ 4 | 5 | // No setup 6 | 7 | import React from 'react'; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@react-navigation/web", 3 | "version": "2.0.0-alpha.0", 4 | "description": "Tools for react-navigation on web browsers and servers", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist/", 8 | "src/", 9 | "LICENSE.md", 10 | "README.md" 11 | ], 12 | "react-native": "src/index.js", 13 | "scripts": { 14 | "test": "jest", 15 | "lint": "eslint .", 16 | "format": "eslint . --fix", 17 | "build": "babel --no-babelrc --plugins=transform-es2015-modules-commonjs,transform-react-jsx,transform-class-properties,transform-object-rest-spread,transform-flow-strip-types src --copy-files --out-dir dist --ignore '**/__tests__/**'", 18 | "prepare": "yarn build", 19 | "release": "release-it" 20 | }, 21 | "publishConfig": { 22 | "registry": "https://registry.npmjs.org/" 23 | }, 24 | "keywords": [ 25 | "react-navigation", 26 | "routing", 27 | "ios", 28 | "android", 29 | "stack" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/react-navigation/react-navigation-web.git" 34 | }, 35 | "author": "", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/react-navigation/react-navigation-web/issues" 39 | }, 40 | "homepage": "https://github.com/react-navigation/react-navigation-web#readme", 41 | "dependencies": { 42 | "history": "^4.7.2", 43 | "query-string": "^6.2.0" 44 | }, 45 | "devDependencies": { 46 | "@babel/runtime": "^7.4.5", 47 | "@react-navigation/core": "^3.4.2", 48 | "babel-cli": "^6.26.0", 49 | "babel-jest": "^22.4.1", 50 | "babel-plugin-transform-class-properties": "^6.13.0", 51 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 52 | "babel-plugin-transform-flow-strip-types": "^6.22.0", 53 | "babel-plugin-transform-object-rest-spread": "^6.13.0", 54 | "babel-plugin-transform-react-jsx": "^6.18.0", 55 | "babel-preset-react-native": "^4.0.0", 56 | "conventional-changelog-cli": "^2.0.5", 57 | "eslint": "^4.12.1", 58 | "eslint-config-satya164": "^1.0.1", 59 | "eslint-plugin-react-native-globals": "^0.1.0", 60 | "husky": "^0.14.3", 61 | "jest": "^22.1.3", 62 | "jest-expo": "^30.0.0", 63 | "prettier": "^1.8.2", 64 | "react": "16.3.1", 65 | "react-dom": "16.3.1", 66 | "react-native": "^0.55.4", 67 | "react-native-safe-area-context": "^0.3.5", 68 | "react-test-renderer": "16.3.1", 69 | "release-it": "^7.6.1" 70 | }, 71 | "peerDependencies": { 72 | "@react-navigation/core": "^3.0.0-alpha.13", 73 | "react": "*", 74 | "react-dom": "*" 75 | }, 76 | "jest": { 77 | "preset": "react-native", 78 | "testRegex": "/__tests__/[^/]+-test\\.js$", 79 | "setupFiles": [ 80 | "/jest-setup.js" 81 | ], 82 | "coveragePathIgnorePatterns": [ 83 | "jest-setup.js" 84 | ], 85 | "modulePathIgnorePatterns": [ 86 | "/example/" 87 | ], 88 | "transformIgnorePatterns": [ 89 | "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-navigation/core)" 90 | ] 91 | }, 92 | "prettier": { 93 | "trailingComma": "es5", 94 | "singleQuote": true 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Link.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { withNavigation, NavigationActions } from '@react-navigation/core'; 3 | import queryString from 'query-string'; 4 | 5 | const getTopNavigation = navigation => { 6 | const parent = navigation.dangerouslyGetParent(); 7 | if (parent) { 8 | return getTopNavigation(parent); 9 | } 10 | return navigation; 11 | }; 12 | 13 | class LinkWithNavigation extends Component { 14 | render() { 15 | const { 16 | children, 17 | params, 18 | routeName, 19 | routeKey, 20 | navigation, 21 | action, 22 | } = this.props; 23 | const topNavigation = getTopNavigation(navigation); 24 | const topRouter = topNavigation.router; 25 | const navAction = 26 | action || 27 | NavigationActions.navigate({ 28 | routeName, 29 | key: routeKey, 30 | params, 31 | }); 32 | if (!action && !routeName && !routeKey) { 33 | throw new Error( 34 | 'Must provide a routeName, routeKey, or a navigation action prop to ' 35 | ); 36 | } 37 | if (action && routeKey) { 38 | throw new Error( 39 | 'Cannot specify a conflicting "routeKey" and a navigation "action" prop. Either use routeName with routeKey to specify a navigate action, or provide the specific navigation "action" prop.' 40 | ); 41 | } 42 | if (action && routeName) { 43 | throw new Error( 44 | 'Cannot specify a conflicting "routeName" and a navigation "action" prop. Either use routeName with routeKey to specify a navigate action, or provide the specific navigation "action" prop.' 45 | ); 46 | } 47 | const navActionResponse = topRouter.getStateForAction( 48 | navAction, 49 | topNavigation.state 50 | ); 51 | const nextState = 52 | navActionResponse === null ? topNavigation.state : navActionResponse; 53 | const pathAndParams = topRouter.getPathAndParamsForState(nextState); 54 | const href = Object.keys(pathAndParams.params).length 55 | ? `/${pathAndParams.path}?${queryString.stringify(pathAndParams.params)}` 56 | : `/${pathAndParams.path}`; 57 | return ( 58 | { 61 | navigation.dispatch(navAction); 62 | e.preventDefault(); 63 | }} 64 | > 65 | {children} 66 | 67 | ); 68 | } 69 | } 70 | const Link = withNavigation(LinkWithNavigation); 71 | 72 | export default Link; 73 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/createBrowserApp-test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SwitchNavigator works on web renders successfully 1`] = ` 4 |
5 | Other Screen 6 |
7 | `; 8 | -------------------------------------------------------------------------------- /src/__tests__/createBrowserApp-test.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import renderer from 'react-test-renderer'; 3 | import { createSwitchNavigator } from '@react-navigation/core'; 4 | import createBrowserApp from '../createBrowserApp'; 5 | 6 | class HomeScreen extends Component { 7 | static navigationOptions = () => ({ 8 | title: 'Home', 9 | }); 10 | 11 | render() { 12 | return
Hello Home
; 13 | } 14 | } 15 | 16 | class OtherScreen extends Component { 17 | static navigationOptions = () => ({ 18 | title: 'Other', 19 | }); 20 | 21 | render() { 22 | return
Other Screen
; 23 | } 24 | } 25 | 26 | jest.mock('history', () => ({ 27 | createBrowserHistory: () => ({ 28 | push: () => {}, 29 | listen: () => {}, 30 | location: { 31 | pathname: '/OtherScreen', 32 | search: '?foo=bar', 33 | }, 34 | }), 35 | })); 36 | 37 | global.document = { 38 | title: 'Empty Title', 39 | }; 40 | 41 | describe('SwitchNavigator works on web', () => { 42 | test('renders successfully', () => { 43 | const AppNavigator = createSwitchNavigator({ 44 | HomeScreen, 45 | OtherScreen, 46 | }); 47 | const App = createBrowserApp(AppNavigator); 48 | const rendered = renderer.create().toJSON(); 49 | 50 | expect(rendered).toMatchSnapshot(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/createBrowserApp.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import { 4 | createBrowserHistory, 5 | createHashHistory, 6 | createMemoryHistory, 7 | } from 'history'; 8 | import React from 'react'; 9 | import { 10 | NavigationActions, 11 | getNavigation, 12 | NavigationProvider, 13 | } from '@react-navigation/core'; 14 | import { SafeAreaProvider } from 'react-native-safe-area-context'; 15 | 16 | /* eslint-disable import/no-commonjs */ 17 | const queryString = require('query-string'); 18 | 19 | const getPathAndParamsFromLocation = location => { 20 | const path = encodeURI(location.pathname.substr(1)); 21 | const params = queryString.parse(location.search); 22 | return { path, params }; 23 | }; 24 | 25 | const matchPathAndParams = (a, b) => { 26 | if (a.path !== b.path) { 27 | return false; 28 | } 29 | if (queryString.stringify(a.params) !== queryString.stringify(b.params)) { 30 | return false; 31 | } 32 | return true; 33 | }; 34 | 35 | function getHistory(history) { 36 | if (typeof history === 'string') { 37 | switch (history) { 38 | case 'browser': 39 | return createBrowserHistory(); 40 | case 'hash': 41 | return createHashHistory(); 42 | case 'memory': 43 | return createMemoryHistory(); 44 | default: 45 | throw new Error( 46 | '@react-navigation/web: createBrowserApp() Invalid value for options.history ' + 47 | history 48 | ); 49 | } 50 | } 51 | return history || createBrowserHistory(); 52 | } 53 | 54 | export default function createBrowserApp(App, { history: historyOption } = {}) { 55 | const history = getHistory(historyOption); 56 | let currentPathAndParams = getPathAndParamsFromLocation(history.location); 57 | const initAction = 58 | App.router.getActionForPathAndParams( 59 | currentPathAndParams.path, 60 | currentPathAndParams.params 61 | ) || NavigationActions.init(); 62 | 63 | const setHistoryListener = dispatch => { 64 | history.listen(location => { 65 | const pathAndParams = getPathAndParamsFromLocation(location); 66 | if (matchPathAndParams(pathAndParams, currentPathAndParams)) { 67 | return; 68 | } 69 | currentPathAndParams = pathAndParams; 70 | const action = App.router.getActionForPathAndParams( 71 | pathAndParams.path, 72 | pathAndParams.params 73 | ); 74 | if (action) { 75 | dispatch(action); 76 | } else { 77 | dispatch(initAction); 78 | } 79 | }); 80 | }; 81 | 82 | class WebApp extends React.Component { 83 | state = { nav: App.router.getStateForAction(initAction) }; 84 | _title = document.title; 85 | _actionEventSubscribers = new Set(); 86 | componentDidMount() { 87 | setHistoryListener(this.dispatch); 88 | this.updateTitle(); 89 | this._actionEventSubscribers.forEach(subscriber => 90 | subscriber({ 91 | type: 'action', 92 | action: initAction, 93 | state: this.state.nav, 94 | lastState: null, 95 | }) 96 | ); 97 | } 98 | componentDidUpdate() { 99 | this.updateTitle(); 100 | } 101 | updateTitle() { 102 | const { state } = this._navigation; 103 | const childKey = state.routes[state.index].key; 104 | const activeNav = this._navigation.getChildNavigation(childKey); 105 | const opts = App.router.getScreenOptions(activeNav); 106 | this._title = opts.title || opts.headerTitle; 107 | if (this._title) { 108 | document.title = this._title; 109 | } 110 | } 111 | 112 | _onNavigationStateChange(prevNav, nav, action) { 113 | if (typeof this.props.onNavigationStateChange === 'function') { 114 | this.props.onNavigationStateChange(prevNav, nav, action); 115 | } 116 | } 117 | 118 | render() { 119 | this._navigation = getNavigation( 120 | App.router, 121 | this.state.nav, 122 | this.dispatch, 123 | this._actionEventSubscribers, 124 | () => this.props.screenProps, 125 | () => this._navigation 126 | ); 127 | return ( 128 | 129 | 130 | 131 | 132 | 133 | ); 134 | } 135 | dispatch = action => { 136 | const lastState = this.state.nav; 137 | const newState = App.router.getStateForAction(action, lastState); 138 | const dispatchEvents = () => 139 | this._actionEventSubscribers.forEach(subscriber => 140 | subscriber({ 141 | type: 'action', 142 | action, 143 | state: newState, 144 | lastState, 145 | }) 146 | ); 147 | if (newState && newState !== lastState) { 148 | this.setState({ nav: newState }, () => { 149 | this._onNavigationStateChange(lastState, newState, action); 150 | dispatchEvents(); 151 | }); 152 | const pathAndParams = 153 | App.router.getPathAndParamsForState && 154 | App.router.getPathAndParamsForState(newState); 155 | if ( 156 | pathAndParams && 157 | !matchPathAndParams(pathAndParams, currentPathAndParams) 158 | ) { 159 | currentPathAndParams = pathAndParams; 160 | history.push( 161 | `/${pathAndParams.path}?${queryString.stringify( 162 | pathAndParams.params 163 | )}` 164 | ); 165 | } 166 | } else { 167 | dispatchEvents(); 168 | } 169 | }; 170 | } 171 | return WebApp; 172 | } 173 | -------------------------------------------------------------------------------- /src/handleServerRequest.js: -------------------------------------------------------------------------------- 1 | import { NavigationActions, getNavigation } from '@react-navigation/core'; 2 | 3 | export default function handleServerRequest( 4 | Router, 5 | pathWithLeadingSlash, 6 | query 7 | ) { 8 | const path = pathWithLeadingSlash.slice(1); 9 | 10 | // Get initial action from the URL 11 | const navigationAction = 12 | Router.getActionForPathAndParams(path, query) || NavigationActions.init(); 13 | 14 | // Get state from reducer 15 | const navigationState = Router.getStateForAction(navigationAction); 16 | 17 | const actionSubscribers = new Set(); 18 | 19 | // Prepare top-level navigation prop 20 | let navigation = null; 21 | function getCurrentNavigation() { 22 | return navigation; 23 | } 24 | 25 | navigation = getNavigation( 26 | Router, 27 | navigationState, 28 | () => {}, 29 | actionSubscribers, 30 | () => ({}), 31 | getCurrentNavigation 32 | ); 33 | 34 | // Get title from active screen options 35 | const activeKey = navigationState.routes[navigationState.index].key; 36 | const activeChildNavigation = navigation.getChildNavigation(activeKey); 37 | const options = Router.getScreenOptions(activeChildNavigation); 38 | const title = options.title || options.headerTitle; 39 | 40 | return { navigation, title, options }; 41 | } 42 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as createBrowserApp } from './createBrowserApp'; 2 | export { default as handleServerRequest } from './handleServerRequest'; 3 | export { default as Link } from './Link'; 4 | --------------------------------------------------------------------------------