├── babel.config.js ├── changelog.md ├── webpack.config.js ├── LICENSE ├── .gitignore ├── package.json ├── router.js ├── router.min.js └── readme.md /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | presets: [ 5 | '@babel/preset-env', 6 | '@babel/preset-react' 7 | ], 8 | plugins: [] 9 | }; 10 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | ## 0.4 - Ignore links 2 | 3 | Any link with the `target` attribute will be ignored. 4 | 5 | ## 0.3 - Initial release 6 | 7 | First half-stable version released after some experimentation. Asking for feedback now. 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const pkg = require('./package.json'); 4 | 5 | module.exports = { 6 | entry: path.join(__dirname, "./router.js"), 7 | mode: 'production', 8 | output: { 9 | path: __dirname, 10 | filename: 'router.min.js', 11 | library: 'router', 12 | libraryTarget: 'umd', 13 | umdNamedDefine: true 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.(js|jsx)$/, 19 | exclude: /node_modules/, 20 | use: ['babel-loader'] 21 | } 22 | ] 23 | }, 24 | resolve: { 25 | extensions: ['*', '.js', '.jsx'] 26 | }, 27 | resolve: { 28 | alias: { 29 | 'react': path.resolve(__dirname, './node_modules/react') 30 | } 31 | }, 32 | externals: { 33 | // Don't bundle react 34 | react: { 35 | commonjs: "react", 36 | commonjs2: "react", 37 | amd: "React", 38 | root: "React" 39 | } 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Francisco Presencia 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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-plain-router", 3 | "version": "0.5.0", 4 | "description": "🛣 A 2kb React router that works exactly as expected with native Javascript", 5 | "main": "router.min.js", 6 | "scripts": { 7 | "build": "webpack -c", 8 | "test": "echo '😉'", 9 | "gzip": "gzip -c router.js | wc -c && echo 'bytes' # Only for Unix", 10 | "watch": "jest --coverage" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/franciscop/router.git" 15 | }, 16 | "keywords": [], 17 | "author": "Francisco Presencia (https://francisco.io/)", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/franciscop/router/issues" 21 | }, 22 | "homepage": "https://github.com/franciscop/router#readme", 23 | "devDependencies": { 24 | "@babel/core": "^7.1.5", 25 | "@babel/preset-env": "^7.1.5", 26 | "@babel/preset-react": "^7.0.0", 27 | "babel-core": "^7.0.0-bridge.0", 28 | "babel-jest": "^23.6.0", 29 | "babel-loader": "^8.0.4", 30 | "jest": "^23.6.0", 31 | "react": "^16.3.0", 32 | "react-dom": "^16.3.0", 33 | "regenerator-runtime": "^0.12.1", 34 | "uglify-es": "^3.1.3", 35 | "webpack": "^4.25.0", 36 | "webpack-cli": "^3.1.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /router.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | const internal = link => !/^(https?:\/\/|\/\/)/i.test(link); 3 | 4 | const getQuery = ({ searchParams }) => { 5 | const query = {}; 6 | for (let key of searchParams) { 7 | query[key[0]] = key[1]; 8 | } 9 | if (!Object.keys(query).length) return false; 10 | return query; 11 | }; 12 | 13 | const pair = ([k,v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v); 14 | 15 | const createQuery = query => { 16 | if (!query || !Object.keys(query).length) return ''; 17 | return '?' + Object.entries(query).map(pair).join('&'); 18 | }; 19 | 20 | const getState = loc => { 21 | if (loc.path) return loc; 22 | if (typeof loc === 'string') return getState(new URL(loc, window.location)); 23 | const query = getQuery(loc); 24 | return { path: loc.pathname, query, hash: (loc.hash && loc.hash.slice(1)) || false }; 25 | }; 26 | 27 | const full = ({ path, search, hash }) => path + createQuery(search) + (hash ? ('#' + hash) : ''); 28 | 29 | export default Comp => class extends Component { 30 | constructor (props) { 31 | super(props); 32 | this.state = getState(document.location.href); 33 | this.upgrade = this.upgrade.bind(this); 34 | this.browser = this.browser.bind(this); 35 | this.clicked = this.clicked.bind(this); 36 | this.refresh = this.refresh.bind(this); 37 | } 38 | upgrade (state) { 39 | if (!state) state = getState(document.location.href); 40 | if (full(state) === full(this.state)) return; 41 | window.dispatchEvent(new CustomEvent('navigation', { detail: state })); 42 | this.setState(state); 43 | } 44 | clicked (e) { 45 | const link = e.target.closest('a[href]'); 46 | if (!link) return; 47 | if (link.hasAttribute('target')) return; 48 | const href = link.getAttribute('href'); 49 | if (!internal(href)) return; 50 | e.preventDefault(); 51 | const state = getState(href); 52 | window.history.pushState(state, link.innerText, full(state)); 53 | this.upgrade(state); 54 | } 55 | // For browser events like forward, backwards, etc 56 | browser ({ state }) { 57 | this.upgrade(state); 58 | } 59 | // You should not be manually setting the url if possible at all, but just in 60 | // case here we listen to potential manual changes 61 | refresh () { 62 | this.upgrade(getState(document.location.href)); 63 | } 64 | componentDidMount () { 65 | document.body.addEventListener('click', this.clicked); 66 | window.addEventListener('popstate', this.browser); 67 | this.interval = window.setInterval(this.refresh, 100); 68 | } 69 | componentWillUnmount () { 70 | document.body.removeEventListener('click', this.clicked); 71 | window.removeEventListener('popstate', this.browser); 72 | window.clearInterval(this.interval); 73 | } 74 | render () { 75 | return ; 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /router.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("react")):"function"==typeof define&&define.amd?define("router",["React"],t):"object"==typeof exports?exports.router=t(require("react")):e.router=t(e.React)}(window,function(e){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(t,n){t.exports=e},function(e,t,n){"use strict";n.r(t);var r=n(0),o=n.n(r);function i(e){return(i="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function u(){return(u=Object.assign||function(e){for(var t=1;t ( 12 |
13 | 17 | 18 | {path === '/' &&
Hello world!
} 19 | {path === '/about' &&
About me
} 20 |
21 | )); 22 | ``` 23 | 24 | You have to wrap your app with `router()` and then both `` links and `window.history.pushState()` will work as expected. It will trigger a re-render when any of these properties change: `path`, `query` or `hash`. 25 | 26 | If you have parameters or complex routes you can combine it with my other library [`pagex`](https://github.com/franciscop/pagex) for a cleaner syntax: 27 | 28 | ```js 29 | import router from 'react-plain-router'; 30 | import page from 'pagex'; 31 | export default router(() => ( 32 |
33 | {page('/', () =>
Hello world!
)} 34 | {page('/users', () =>
    ...
)} 35 | {page('/users/:id', id => )} 36 |
37 | )); 38 | ``` 39 | 40 | If a link has the attribute `target`, then this library will ignore it and let the browser handle it. This is very useful for `target="_blank"`, so that the link is opened in a new tab as usual. But it will also work with `target="_self"`, where the router will ignore the link and thus perform a full-refresh. 41 | 42 | An event named `navigation` will be triggered on `window` every time there's a re-render. You can see the parts with `e.detail`, which is very useful for debugging: 43 | 44 | ```js 45 | // When loading /greetings?hello=world#nice 46 | window.addEventListener('navigation', e => { 47 | console.log('NAVIGATION', e.detail); 48 | // { 49 | // path: '/greetings', 50 | // query: { hello: 'world' }, 51 | // hash: 'nice' 52 | // } 53 | }); 54 | ``` 55 | 56 | **Internally**, it works by using the bubbling events at the document level and then handling any link click. `window.location` becomes the source of truth instead of keeping an context or global store, which makes it more reliable to interact with native Javascript or http events. 57 | 58 | ## router(cb) 59 | 60 | This [HOC](https://reactjs.org/docs/higher-order-components.html) function accepts a callback, which will be passed an arguments with the props from above and these 3 extra props: 61 | 62 | - `path`, `pathname` (String): the current url path, similar to the native `pathname`. Example: for `/greetings` it will be `'/greetings'`. An empty URL would be `'/'`. 63 | - `query` (Object | false): an object with key:values for the query in the url. Example: for `/greeting?hello=world` it will be `{ hello: 'world' }`. 64 | - `hash` (String | false): the hash value without the `#`. Example: for `/hello#there` it will be `'there'`. 65 | 66 | A fully qualified url will parse as this: 67 | 68 | ```js 69 | // /greetings?hello=world#nice 70 | router(({ path, query, hash, ...props }) => { 71 | expect(path).toBe('/greetings'); 72 | expect(query).toEqual({ hello: 'world' }); 73 | expect(hash).toBe('nice'); 74 | }); 75 | ``` 76 | 77 | 78 | 79 | 80 | ## Example: navigation bar 81 | 82 | We can define our navigation in a different component. `
` are native so they will work cross-components: 83 | 84 | ```js 85 | // Nav.js 86 | export default () => ( 87 | 93 | ); 94 | ``` 95 | 96 | Then you can toggle the different pages in the main App.js: 97 | 98 | ```js 99 | // App.js 100 | import router from 'react-plain-router'; 101 | import Nav from './Nav'; 102 | 103 | export default router(({ path }) => ( 104 |
105 |
109 | )); 110 | ``` 111 | 112 | The Google link will open Google, and the Terms and Conditions link will open a new tab. Everything works as expected, in the same way native html works. 113 | 114 | 115 | ## Example: scroll to top on any navigation 116 | 117 | Add an event listener to the navigation event: 118 | 119 | ```js 120 | window.addEventListener('navigation', e => { 121 | window.scrollTo(0, 0); 122 | }); 123 | ``` 124 | 125 | 126 | 127 | ## Example: simulating the `` 128 | 129 | Just an example of how easy it is to work with `react-plain-router`, let's see how to simulate the component that the library `react-router-dom` defines with this library 130 | 131 | ```js 132 | // components/Link.js 133 | export default ({ to, ...props }) => ; 134 | ``` 135 | 136 | Then to use our newly defined component, we can import it and use it: 137 | 138 | ```js 139 | // Home.js 140 | import router from 'react-plain-router'; 141 | import Link from './components/Link'; 142 | 143 | export default router(() => ( 144 | About us 145 | )); 146 | ``` 147 | 148 | But you can just use native links: 149 | 150 | ```js 151 | // Home.js 152 | import router from 'react-plain-router'; 153 | 154 | export default router(() => ( 155 | About us 156 | )); 157 | ``` 158 | 159 | 160 | ## Example: manual navigation 161 | 162 | To trigger manual navigation you can use the native `history.pushState()` as [explained in the amazing Mozilla Developer Network](https://developer.mozilla.org/en-US/docs/Web/API/History_API): 163 | 164 | ```js 165 | // src/actions/login 166 | export default id => async dispatch => { 167 | const payload = await ky(`/api/users/${id}`).json(); 168 | dispatch({ type: 'USER_DATA', payload }); 169 | window.history.pushState({}, 'Dashboard', `/dashboard`); 170 | }; 171 | ``` 172 | --------------------------------------------------------------------------------