├── .editorconfig ├── .github └── workflows │ ├── node.js.yml │ ├── npm-publish.yml │ └── prettier.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── LICENSE ├── README.md ├── config ├── env.js ├── jest │ └── fileTransform.js └── paths.js ├── dist └── .gitkeep ├── docs ├── .gitignore ├── .gitkeep ├── CNAME ├── README.md ├── assets │ ├── et-book │ │ ├── et-book-bold-line-figures │ │ │ ├── et-book-bold-line-figures.eot │ │ │ ├── et-book-bold-line-figures.svg │ │ │ ├── et-book-bold-line-figures.ttf │ │ │ └── et-book-bold-line-figures.woff │ │ ├── et-book-display-italic-old-style-figures │ │ │ ├── et-book-display-italic-old-style-figures.eot │ │ │ ├── et-book-display-italic-old-style-figures.svg │ │ │ ├── et-book-display-italic-old-style-figures.ttf │ │ │ └── et-book-display-italic-old-style-figures.woff │ │ ├── et-book-roman-line-figures │ │ │ ├── et-book-roman-line-figures.eot │ │ │ ├── et-book-roman-line-figures.svg │ │ │ ├── et-book-roman-line-figures.ttf │ │ │ └── et-book-roman-line-figures.woff │ │ ├── et-book-roman-old-style-figures │ │ │ ├── et-book-roman-old-style-figures.eot │ │ │ ├── et-book-roman-old-style-figures.svg │ │ │ ├── et-book-roman-old-style-figures.ttf │ │ │ └── et-book-roman-old-style-figures.woff │ │ └── et-book-semi-bold-old-style-figures │ │ │ ├── et-book-semi-bold-old-style-figures.eot │ │ │ ├── et-book-semi-bold-old-style-figures.svg │ │ │ ├── et-book-semi-bold-old-style-figures.ttf │ │ │ └── et-book-semi-bold-old-style-figures.woff │ ├── highlight.pack.js │ ├── style.css │ ├── tufte.css │ └── zenburn.css ├── index.html ├── index.html.pm ├── pollen.rkt └── template.html ├── package-lock.json ├── package.json ├── provider-api.js ├── publish.sh ├── scripts └── test.js └── src ├── action-link.js ├── action-router.js ├── change-url-event.js ├── fragment.js ├── history-events.js ├── index.js ├── provider-api.js ├── redux-api.js ├── route-dispatcher.js └── tests ├── __snapshots__ ├── action-link.test.js.snap └── fragment.test.js.snap ├── action-link.test.js ├── action-router.test.js ├── change-url-event.test.js ├── fragment.test.js ├── history-events.test.js ├── provider-api.test.js ├── route-dispatcher.test.js └── test-utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [10.x, 12.x, 14.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm ci 27 | - run: npm run build --if-present 28 | - run: npm test 29 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - run: npm ci 19 | - run: npm test 20 | - run: npm run build 21 | 22 | publish-npm: 23 | needs: build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: 12 30 | registry-url: https://registry.npmjs.org/ 31 | - run: npm ci 32 | - run: npm run build 33 | - run: npm publish 34 | env: 35 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 36 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Format (prettier) 2 | 3 | on: 4 | push: 5 | pull_request: 6 | paths: 7 | - "**.css" 8 | - "**.js" 9 | - "**.json" 10 | 11 | jobs: 12 | prettier: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | ref: ${{ github.head_ref }} 18 | 19 | 20 | - name: Install 21 | run: npm ci 22 | env: 23 | CI: true 24 | 25 | - name: Run formatter 26 | run: npm run format 27 | 28 | - uses: stefanzweifel/git-auto-commit-action@v4 29 | with: 30 | commit_message: 'chore: reformat' 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # IDE 13 | .idea 14 | /*.iml 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # TODO list 28 | TODO 29 | /dist/* 30 | .tern-port 31 | [#]*[#] 32 | :* 33 | *~ 34 | .history 35 | docs/assets 36 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # IDE 13 | .idea 14 | /*.iml 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | /docs 28 | .travis.yml 29 | .tern-port 30 | [#]*[#] 31 | :* 32 | *~ 33 | # TODO list 34 | TODO 35 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | dist 3 | node_modules 4 | .github 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 CJ Engineering 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 | # Routedux — Routes the Redux Way [![npm version](https://badge.fury.io/js/routedux.svg)](https://badge.fury.io/js/routedux) ![Node.js CI](https://github.com/cjdev/routedux/workflows/Node.js%20CI/badge.svg) 2 | 3 | Route Dux 4 | 5 | Routedux (:duck: :duck: :duck:) routes URLs to Redux actions and vice versa. 6 | 7 | (v1.x only works with React >=16) 8 | 9 | Your application doesn't need to know it lives in a browser, but your 10 | users want pretty urls and deep links. 11 | 12 | ## Wait, my application doesn't need to know it lives in a browser? 13 | 14 | URLs are great for finding things on the internet. But a single page 15 | application is not the same as a collection of resources that lives on 16 | a remote server. 17 | 18 | A single page application is a web application only in the sense that 19 | it lives on the web. URLs are are not essential 20 | to it working well. 21 | 22 | URLs give users accessing your application in a browser the ability to 23 | bookmark a particular view in your application so that their 24 | expectation of browser-based applications will continue to work. 25 | 26 | We think that's a good thing, but we also don't think the idea of url 27 | paths should be littered through your application. 28 | 29 | When you are developing a redux application, you want your UI to be a 30 | pure function of the current state tree. 31 | 32 | By adding routes to that, it makes it harder to test. And this 33 | difficulty can be compounded by other decisions about how to add 34 | routes to your application. 35 | 36 | ## An alternative approach 37 | 38 | React Router is the currently-accepted way to do URL routing in React 39 | applications. For a standard React application without Redux, this 40 | solution isn't too bad. But once you add Redux, things get difficult. 41 | 42 | We basically discovered the same lessons as Formidable Labs: 43 | [React Router is the wrong way to route in Redux apps.](http://formidable.com/blog/2016/07/11/let-the-url-do-the-talking-part-1-the-pain-of-react-router-in-redux/) 44 | 45 | However, we don't think their solution 46 | ([redux-little-router](https://github.com/FormidableLabs/redux-little-router)) 47 | goes far enough, as it still embeds the idea of routes throughout your 48 | user interface. 49 | 50 | Once you separate URLs from your application state, you can easily 51 | port it to other environments that don't know what URLs are, and by 52 | simply removing the routing declaration, things will work as before. 53 | 54 | As an added (and we think absolutely essential) benefit, your entire 55 | application becomes easier to test, as rendering is a pure function of 56 | Redux state, and model logic and route actions are entirely 57 | encapsulated in Redux outside of the app. 58 | 59 | ## Demo Site 60 | 61 | See a simple [demo documentation site.](https://github.com/cjdev/routedux-docs-demo) 62 | 63 | ## Simple Routing in 25 lines 64 | 65 | ```javascript 66 | import installBrowserRouter from 'routedux'; 67 | import {createStore, compose} from 'redux'; 68 | 69 | const LOAD_USER = 'LOAD_USER'; 70 | 71 | function currentUserId() { 72 | return 42; 73 | }; 74 | 75 | function reduce(state = initialState(), action) { 76 | ... 77 | } 78 | 79 | const routesConfig = [ 80 | ['/user/:id', LOAD_USER, {}], 81 | ['/user/me', LOAD_USER, {id: currentUserId()}], 82 | ['/article/:slug', 'LOAD_ARTICLE', {}], 83 | ['/', 'LOAD_ARTICLE', {slug: "home-content"}] 84 | ]; 85 | 86 | const {enhancer, init} = installBrowserRouter(routesConfig); 87 | 88 | const store = createStore(reduce, compose( 89 | enhancer 90 | )); 91 | 92 | //when you are ready to handle the initial page load (redux-saga and similar libraries necessitate this being separte) 93 | init(); 94 | 95 | ``` 96 | 97 | Any time a handled action fires the url in the address bar will 98 | change, and if the url in the address bar changes the corresponding 99 | action will fire (unless the action was initiated by a url change). 100 | 101 | ## Route matching precedence - which route matches best? 102 | 103 | Route precedence is a function of the type of matching done in each 104 | segment and the order in which the wildcard segments match. Exact 105 | matches are always preferred to wildcards moving from left to right. 106 | 107 | ```javascript 108 | const routesInOrderOfPrecedence = [ 109 | ["/user/me/update", "/user/me"], // both perfectly specific - will match above any wildcard route 110 | "/user/me/:view", 111 | "/user/:id/update", // less specific because 'me' is exact match, while :id is a wildcard 112 | "/user/:id/:view", 113 | ]; 114 | ``` 115 | 116 | ## Fragment component 117 | 118 | Given that every UI state will be in your state tree as a function of 119 | your reducer logic, you can express any restriction on which parts of 120 | the UI display, even those that have nothing to do with the specific 121 | transformations caused by your URL actions. 122 | 123 | ```javascript 124 | 125 | const state = { 126 | menu: ... 127 | } 128 | 129 | const view = ( 130 | 131 | 132 | 133 | 134 | 135 | ) 136 | 137 | // If menu is truthy, this renders as: 138 | 139 | ( 140 | 141 | 142 | 143 | ) 144 | 145 | // If menu is falsy, this renders as: 146 | ( 147 | 148 | 149 | ) 150 | 151 | // If property is missing in path, it's falsy. 152 | 153 | const view = ( 154 | 155 | 156 | 157 | 158 | 159 | ) 160 | 161 | // Renders as: 162 | ( 163 | 164 | 165 | ) 166 | 167 | ``` 168 | 169 | ## ActionLink and pathForAction(action) 170 | 171 | Occasionally it is nice to render URLs inside of your application. 172 | 173 | As a convenience, we have attached `pathForAction` to the `store` 174 | object, which uses the same matcher that the action matcher uses. 175 | This allows you to create links in your application by using the 176 | actions. 177 | 178 | ```javascript 179 | const routesConfig = [ 180 | ['/user/:id', LOAD_USER, {}], 181 | ['/user/me', LOAD_USER, {id: currentUserId()}] 182 | ]; 183 | /* ... do store initialization */ 184 | 185 | store.pathForAction({type:LOAD_USER, id: currentUserId()}); 186 | /* returns /user/me */ 187 | 188 | /* ActionLink */ 189 | 190 | import { ActionLink as _ActionLink } from "routedux"; 191 | 192 | const store = createStore(...); 193 | 194 | class ActionLink extends _ActionLink { 195 | constructor(props) { 196 | super({ ...props }); 197 | this.store = store; 198 | } 199 | } 200 | 201 | const action = { 202 | type: LOAD_USER, 203 | id: 123 204 | }; 205 | 206 | return ( 207 | Link Text 208 | ); 209 | 210 | /* renders as a link to Link Text with the text */ 211 | ``` 212 | 213 | Now you have links, but your links always stay up to date with your 214 | routing configuration. 215 | -------------------------------------------------------------------------------- /config/env.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const paths = require("./paths"); 4 | 5 | // Make sure that including paths.js after env.js will read .env variables. 6 | delete require.cache[require.resolve("./paths")]; 7 | 8 | const NODE_ENV = process.env.NODE_ENV; 9 | if (!NODE_ENV) { 10 | throw new Error( 11 | "The NODE_ENV environment variable is required but was not specified." 12 | ); 13 | } 14 | 15 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 16 | var dotenvFiles = [ 17 | `${paths.dotenv}.${NODE_ENV}.local`, 18 | `${paths.dotenv}.${NODE_ENV}`, 19 | // Don't include `.env.local` for `test` environment 20 | // since normally you expect tests to produce the same 21 | // results for everyone 22 | NODE_ENV !== "test" && `${paths.dotenv}.local`, 23 | paths.dotenv, 24 | ].filter(Boolean); 25 | 26 | // Load environment variables from .env* files. Suppress warnings using silent 27 | // if this file is missing. dotenv will never modify any environment variables 28 | // that have already been set. Variable expansion is supported in .env files. 29 | // https://github.com/motdotla/dotenv 30 | // https://github.com/motdotla/dotenv-expand 31 | dotenvFiles.forEach(dotenvFile => { 32 | if (fs.existsSync(dotenvFile)) { 33 | require("dotenv-expand")( 34 | require("dotenv").config({ 35 | path: dotenvFile, 36 | }) 37 | ); 38 | } 39 | }); 40 | 41 | // We support resolving modules according to `NODE_PATH`. 42 | // This lets you use absolute paths in imports inside large monorepos: 43 | // https://github.com/facebook/create-react-app/issues/253. 44 | // It works similar to `NODE_PATH` in Node itself: 45 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 46 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 47 | // Otherwise, we risk importing Node.js core modules into an app instead of Webpack shims. 48 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 49 | // We also resolve them to make sure all tools using them work consistently. 50 | const appDirectory = fs.realpathSync(process.cwd()); 51 | process.env.NODE_PATH = (process.env.NODE_PATH || "") 52 | .split(path.delimiter) 53 | .filter(folder => folder && !path.isAbsolute(folder)) 54 | .map(folder => path.resolve(appDirectory, folder)) 55 | .join(path.delimiter); 56 | 57 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 58 | // injected into the application via DefinePlugin in Webpack configuration. 59 | const REACT_APP = /^REACT_APP_/i; 60 | 61 | function getClientEnvironment(publicUrl) { 62 | const raw = Object.keys(process.env) 63 | .filter(key => REACT_APP.test(key)) 64 | .reduce( 65 | (env, key) => { 66 | env[key] = process.env[key]; 67 | return env; 68 | }, 69 | { 70 | // Useful for determining whether we’re running in production mode. 71 | // Most importantly, it switches React into the correct mode. 72 | NODE_ENV: process.env.NODE_ENV || "development", 73 | // Useful for resolving the correct path to static assets in `public`. 74 | // For example, . 75 | // This should only be used as an escape hatch. Normally you would put 76 | // images into the `src` and `import` them in code to get their paths. 77 | PUBLIC_URL: publicUrl, 78 | } 79 | ); 80 | // Stringify all values so we can feed into Webpack DefinePlugin 81 | const stringified = { 82 | "process.env": Object.keys(raw).reduce((env, key) => { 83 | env[key] = JSON.stringify(raw[key]); 84 | return env; 85 | }, {}), 86 | }; 87 | 88 | return { raw, stringified }; 89 | } 90 | 91 | module.exports = getClientEnvironment; 92 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | // This is a custom Jest transformer turning file imports into filenames. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process(src, filename) { 8 | const assetFilename = JSON.stringify(path.basename(filename)); 9 | 10 | if (filename.match(/\.svg$/)) { 11 | return `const React = require('react'); 12 | module.exports = { 13 | __esModule: true, 14 | default: ${assetFilename}, 15 | ReactComponent: React.forwardRef((props, ref) => ({ 16 | $$typeof: Symbol.for('react.element'), 17 | type: 'svg', 18 | ref: ref, 19 | key: null, 20 | props: Object.assign({}, props, { 21 | children: ${assetFilename} 22 | }) 23 | })), 24 | };`; 25 | } 26 | 27 | return `module.exports = ${assetFilename};`; 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const fs = require("fs"); 3 | const url = require("url"); 4 | 5 | // Make sure any symlinks in the project folder are resolved: 6 | // https://github.com/facebook/create-react-app/issues/637 7 | const appDirectory = fs.realpathSync(process.cwd()); 8 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 9 | 10 | const envPublicUrl = process.env.PUBLIC_URL; 11 | 12 | function ensureSlash(inputPath, needsSlash) { 13 | const hasSlash = inputPath.endsWith("/"); 14 | if (hasSlash && !needsSlash) { 15 | return inputPath.substr(0, inputPath.length - 1); 16 | } else if (!hasSlash && needsSlash) { 17 | return `${inputPath}/`; 18 | } else { 19 | return inputPath; 20 | } 21 | } 22 | 23 | const getPublicUrl = appPackageJson => 24 | envPublicUrl || require(appPackageJson).homepage; 25 | 26 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 27 | // "public path" at which the app is served. 28 | // Webpack needs to know it to put the right ", 1503 | returnEnd: !0, 1504 | subLanguage: ["javascript", "handlebars", "xml"], 1505 | }, 1506 | }, 1507 | { 1508 | className: "tag", 1509 | begin: "", 1511 | contains: [ 1512 | { className: "name", begin: /[^\/><\s]+/, relevance: 0 }, 1513 | c, 1514 | ], 1515 | }, 1516 | ], 1517 | }; 1518 | }; 1519 | })() 1520 | ); 1521 | hljs.registerLanguage( 1522 | "nginx", 1523 | (function () { 1524 | "use strict"; 1525 | return function (e) { 1526 | var n = { 1527 | className: "variable", 1528 | variants: [ 1529 | { begin: /\$\d+/ }, 1530 | { begin: /\$\{/, end: /}/ }, 1531 | { begin: "[\\$\\@]" + e.UNDERSCORE_IDENT_RE }, 1532 | ], 1533 | }, 1534 | a = { 1535 | endsWithParent: !0, 1536 | keywords: { 1537 | $pattern: "[a-z/_]+", 1538 | literal: 1539 | "on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll", 1540 | }, 1541 | relevance: 0, 1542 | illegal: "=>", 1543 | contains: [ 1544 | e.HASH_COMMENT_MODE, 1545 | { 1546 | className: "string", 1547 | contains: [e.BACKSLASH_ESCAPE, n], 1548 | variants: [ 1549 | { begin: /"/, end: /"/ }, 1550 | { begin: /'/, end: /'/ }, 1551 | ], 1552 | }, 1553 | { 1554 | begin: "([a-z]+):/", 1555 | end: "\\s", 1556 | endsWithParent: !0, 1557 | excludeEnd: !0, 1558 | contains: [n], 1559 | }, 1560 | { 1561 | className: "regexp", 1562 | contains: [e.BACKSLASH_ESCAPE, n], 1563 | variants: [ 1564 | { begin: "\\s\\^", end: "\\s|{|;", returnEnd: !0 }, 1565 | { begin: "~\\*?\\s+", end: "\\s|{|;", returnEnd: !0 }, 1566 | { begin: "\\*(\\.[a-z\\-]+)+" }, 1567 | { begin: "([a-z\\-]+\\.)+\\*" }, 1568 | ], 1569 | }, 1570 | { 1571 | className: "number", 1572 | begin: 1573 | "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b", 1574 | }, 1575 | { 1576 | className: "number", 1577 | begin: "\\b\\d+[kKmMgGdshdwy]*\\b", 1578 | relevance: 0, 1579 | }, 1580 | n, 1581 | ], 1582 | }; 1583 | return { 1584 | name: "Nginx config", 1585 | aliases: ["nginxconf"], 1586 | contains: [ 1587 | e.HASH_COMMENT_MODE, 1588 | { 1589 | begin: e.UNDERSCORE_IDENT_RE + "\\s+{", 1590 | returnBegin: !0, 1591 | end: "{", 1592 | contains: [{ className: "section", begin: e.UNDERSCORE_IDENT_RE }], 1593 | relevance: 0, 1594 | }, 1595 | { 1596 | begin: e.UNDERSCORE_IDENT_RE + "\\s", 1597 | end: ";|{", 1598 | returnBegin: !0, 1599 | contains: [ 1600 | { 1601 | className: "attribute", 1602 | begin: e.UNDERSCORE_IDENT_RE, 1603 | starts: a, 1604 | }, 1605 | ], 1606 | relevance: 0, 1607 | }, 1608 | ], 1609 | illegal: "[^\\s\\}]", 1610 | }; 1611 | }; 1612 | })() 1613 | ); 1614 | hljs.registerLanguage( 1615 | "css", 1616 | (function () { 1617 | "use strict"; 1618 | return function (e) { 1619 | var n = { 1620 | begin: /(?:[A-Z\_\.\-]+|--[a-zA-Z0-9_-]+)\s*:/, 1621 | returnBegin: !0, 1622 | end: ";", 1623 | endsWithParent: !0, 1624 | contains: [ 1625 | { 1626 | className: "attribute", 1627 | begin: /\S/, 1628 | end: ":", 1629 | excludeEnd: !0, 1630 | starts: { 1631 | endsWithParent: !0, 1632 | excludeEnd: !0, 1633 | contains: [ 1634 | { 1635 | begin: /[\w-]+\(/, 1636 | returnBegin: !0, 1637 | contains: [ 1638 | { className: "built_in", begin: /[\w-]+/ }, 1639 | { 1640 | begin: /\(/, 1641 | end: /\)/, 1642 | contains: [ 1643 | e.APOS_STRING_MODE, 1644 | e.QUOTE_STRING_MODE, 1645 | e.CSS_NUMBER_MODE, 1646 | ], 1647 | }, 1648 | ], 1649 | }, 1650 | e.CSS_NUMBER_MODE, 1651 | e.QUOTE_STRING_MODE, 1652 | e.APOS_STRING_MODE, 1653 | e.C_BLOCK_COMMENT_MODE, 1654 | { className: "number", begin: "#[0-9A-Fa-f]+" }, 1655 | { className: "meta", begin: "!important" }, 1656 | ], 1657 | }, 1658 | }, 1659 | ], 1660 | }; 1661 | return { 1662 | name: "CSS", 1663 | case_insensitive: !0, 1664 | illegal: /[=\/|'\$]/, 1665 | contains: [ 1666 | e.C_BLOCK_COMMENT_MODE, 1667 | { className: "selector-id", begin: /#[A-Za-z0-9_-]+/ }, 1668 | { className: "selector-class", begin: /\.[A-Za-z0-9_-]+/ }, 1669 | { 1670 | className: "selector-attr", 1671 | begin: /\[/, 1672 | end: /\]/, 1673 | illegal: "$", 1674 | contains: [e.APOS_STRING_MODE, e.QUOTE_STRING_MODE], 1675 | }, 1676 | { 1677 | className: "selector-pseudo", 1678 | begin: /:(:)?[a-zA-Z0-9\_\-\+\(\)"'.]+/, 1679 | }, 1680 | { 1681 | begin: "@(page|font-face)", 1682 | lexemes: "@[a-z-]+", 1683 | keywords: "@page @font-face", 1684 | }, 1685 | { 1686 | begin: "@", 1687 | end: "[{;]", 1688 | illegal: /:/, 1689 | returnBegin: !0, 1690 | contains: [ 1691 | { className: "keyword", begin: /@\-?\w[\w]*(\-\w+)*/ }, 1692 | { 1693 | begin: /\s/, 1694 | endsWithParent: !0, 1695 | excludeEnd: !0, 1696 | relevance: 0, 1697 | keywords: "and or not only", 1698 | contains: [ 1699 | { begin: /[a-z-]+:/, className: "attribute" }, 1700 | e.APOS_STRING_MODE, 1701 | e.QUOTE_STRING_MODE, 1702 | e.CSS_NUMBER_MODE, 1703 | ], 1704 | }, 1705 | ], 1706 | }, 1707 | { 1708 | className: "selector-tag", 1709 | begin: "[a-zA-Z-][a-zA-Z0-9_-]*", 1710 | relevance: 0, 1711 | }, 1712 | { 1713 | begin: "{", 1714 | end: "}", 1715 | illegal: /\S/, 1716 | contains: [e.C_BLOCK_COMMENT_MODE, n], 1717 | }, 1718 | ], 1719 | }; 1720 | }; 1721 | })() 1722 | ); 1723 | -------------------------------------------------------------------------------- /docs/assets/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | pre p { 6 | width: auto; 7 | } 8 | -------------------------------------------------------------------------------- /docs/assets/tufte.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | /* Import ET Book styles 4 | adapted from https://github.com/edwardtufte/et-book/blob/gh-pages/et-book.css */ 5 | 6 | @font-face { 7 | font-family: "et-book"; 8 | src: url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot"); 9 | src: url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.eot?#iefix") 10 | format("embedded-opentype"), 11 | url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.woff") 12 | format("woff"), 13 | url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.ttf") 14 | format("truetype"), 15 | url("et-book/et-book-roman-line-figures/et-book-roman-line-figures.svg#etbookromanosf") 16 | format("svg"); 17 | font-weight: normal; 18 | font-style: normal; 19 | } 20 | 21 | @font-face { 22 | font-family: "et-book"; 23 | src: url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot"); 24 | src: url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.eot?#iefix") 25 | format("embedded-opentype"), 26 | url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.woff") 27 | format("woff"), 28 | url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.ttf") 29 | format("truetype"), 30 | url("et-book/et-book-display-italic-old-style-figures/et-book-display-italic-old-style-figures.svg#etbookromanosf") 31 | format("svg"); 32 | font-weight: normal; 33 | font-style: italic; 34 | } 35 | 36 | @font-face { 37 | font-family: "et-book"; 38 | src: url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot"); 39 | src: url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.eot?#iefix") 40 | format("embedded-opentype"), 41 | url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.woff") 42 | format("woff"), 43 | url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.ttf") 44 | format("truetype"), 45 | url("et-book/et-book-bold-line-figures/et-book-bold-line-figures.svg#etbookromanosf") 46 | format("svg"); 47 | font-weight: bold; 48 | font-style: normal; 49 | } 50 | 51 | @font-face { 52 | font-family: "et-book-roman-old-style"; 53 | src: url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot"); 54 | src: url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.eot?#iefix") 55 | format("embedded-opentype"), 56 | url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.woff") 57 | format("woff"), 58 | url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.ttf") 59 | format("truetype"), 60 | url("et-book/et-book-roman-old-style-figures/et-book-roman-old-style-figures.svg#etbookromanosf") 61 | format("svg"); 62 | font-weight: normal; 63 | font-style: normal; 64 | } 65 | 66 | /* Tufte CSS styles */ 67 | html { 68 | font-size: 15px; 69 | } 70 | 71 | body { 72 | width: 87.5%; 73 | margin-left: auto; 74 | margin-right: auto; 75 | padding-left: 12.5%; 76 | font-family: et-book, Palatino, "Palatino Linotype", "Palatino LT STD", 77 | "Book Antiqua", Georgia, serif; 78 | background-color: #fffff8; 79 | color: #111; 80 | max-width: 1400px; 81 | counter-reset: sidenote-counter; 82 | } 83 | 84 | h1 { 85 | font-weight: 400; 86 | margin-top: 4rem; 87 | margin-bottom: 1.5rem; 88 | font-size: 3.2rem; 89 | line-height: 1; 90 | } 91 | 92 | h2 { 93 | font-style: italic; 94 | font-weight: 400; 95 | margin-top: 2.1rem; 96 | margin-bottom: 0; 97 | font-size: 2.2rem; 98 | line-height: 1; 99 | } 100 | 101 | h3 { 102 | font-style: italic; 103 | font-weight: 400; 104 | font-size: 1.7rem; 105 | margin-top: 2rem; 106 | margin-bottom: 0; 107 | line-height: 1; 108 | } 109 | 110 | hr { 111 | display: block; 112 | height: 1px; 113 | width: 55%; 114 | border: 0; 115 | border-top: 1px solid #ccc; 116 | margin: 1em 0; 117 | padding: 0; 118 | } 119 | 120 | p.subtitle { 121 | font-style: italic; 122 | margin-top: 1rem; 123 | margin-bottom: 1rem; 124 | font-size: 1.8rem; 125 | display: block; 126 | line-height: 1; 127 | } 128 | 129 | .numeral { 130 | font-family: et-book-roman-old-style; 131 | } 132 | 133 | .danger { 134 | color: red; 135 | } 136 | 137 | article { 138 | position: relative; 139 | padding: 5rem 0rem; 140 | } 141 | 142 | section { 143 | padding-top: 1rem; 144 | padding-bottom: 1rem; 145 | } 146 | 147 | p, 148 | ol, 149 | ul { 150 | font-size: 1.4rem; 151 | } 152 | 153 | p { 154 | line-height: 2rem; 155 | margin-top: 1.4rem; 156 | margin-bottom: 1.4rem; 157 | padding-right: 0; 158 | vertical-align: baseline; 159 | } 160 | 161 | /* Chapter Epigraphs */ 162 | div.epigraph { 163 | margin: 5em 0; 164 | } 165 | 166 | div.epigraph > blockquote { 167 | margin-top: 3em; 168 | margin-bottom: 3em; 169 | } 170 | 171 | div.epigraph > blockquote, 172 | div.epigraph > blockquote > p { 173 | font-style: italic; 174 | } 175 | 176 | div.epigraph > blockquote > footer { 177 | font-style: normal; 178 | } 179 | 180 | div.epigraph > blockquote > footer > cite { 181 | font-style: italic; 182 | } 183 | /* end chapter epigraphs styles */ 184 | 185 | blockquote { 186 | font-size: 1.4rem; 187 | } 188 | 189 | blockquote p { 190 | width: 55%; 191 | margin-right: 40px; 192 | } 193 | 194 | blockquote footer { 195 | width: 55%; 196 | font-size: 1.1rem; 197 | text-align: right; 198 | } 199 | 200 | section > ol, 201 | section > ul { 202 | width: 45%; 203 | -webkit-padding-start: 5%; 204 | -webkit-padding-end: 5%; 205 | } 206 | 207 | li { 208 | padding: 0.5rem 0; 209 | } 210 | 211 | figure { 212 | padding: 0; 213 | border: 0; 214 | font-size: 100%; 215 | font: inherit; 216 | vertical-align: baseline; 217 | max-width: 55%; 218 | -webkit-margin-start: 0; 219 | -webkit-margin-end: 0; 220 | margin: 0 0 3em 0; 221 | } 222 | 223 | figcaption { 224 | float: right; 225 | clear: right; 226 | margin-top: 0; 227 | margin-bottom: 0; 228 | font-size: 1.1rem; 229 | line-height: 1.6; 230 | vertical-align: baseline; 231 | position: relative; 232 | max-width: 40%; 233 | } 234 | 235 | figure.fullwidth figcaption { 236 | margin-right: 24%; 237 | } 238 | 239 | /* Links: replicate underline that clears descenders */ 240 | a:link, 241 | a:visited { 242 | color: inherit; 243 | } 244 | 245 | a:link { 246 | text-decoration: none; 247 | background: -webkit-linear-gradient(#fffff8, #fffff8), 248 | -webkit-linear-gradient(#fffff8, #fffff8), 249 | -webkit-linear-gradient(#333, #333); 250 | background: linear-gradient(#fffff8, #fffff8), 251 | linear-gradient(#fffff8, #fffff8), linear-gradient(#333, #333); 252 | -webkit-background-size: 0.05em 1px, 0.05em 1px, 1px 1px; 253 | -moz-background-size: 0.05em 1px, 0.05em 1px, 1px 1px; 254 | background-size: 0.05em 1px, 0.05em 1px, 1px 1px; 255 | background-repeat: no-repeat, no-repeat, repeat-x; 256 | text-shadow: 0.03em 0 #fffff8, -0.03em 0 #fffff8, 0 0.03em #fffff8, 257 | 0 -0.03em #fffff8, 0.06em 0 #fffff8, -0.06em 0 #fffff8, 0.09em 0 #fffff8, 258 | -0.09em 0 #fffff8, 0.12em 0 #fffff8, -0.12em 0 #fffff8, 0.15em 0 #fffff8, 259 | -0.15em 0 #fffff8; 260 | background-position: 0% 93%, 100% 93%, 0% 93%; 261 | } 262 | 263 | @media screen and (-webkit-min-device-pixel-ratio: 0) { 264 | a:link { 265 | background-position-y: 87%, 87%, 87%; 266 | } 267 | } 268 | 269 | a:link::selection { 270 | text-shadow: 0.03em 0 #b4d5fe, -0.03em 0 #b4d5fe, 0 0.03em #b4d5fe, 271 | 0 -0.03em #b4d5fe, 0.06em 0 #b4d5fe, -0.06em 0 #b4d5fe, 0.09em 0 #b4d5fe, 272 | -0.09em 0 #b4d5fe, 0.12em 0 #b4d5fe, -0.12em 0 #b4d5fe, 0.15em 0 #b4d5fe, 273 | -0.15em 0 #b4d5fe; 274 | background: #b4d5fe; 275 | } 276 | 277 | a:link::-moz-selection { 278 | text-shadow: 0.03em 0 #b4d5fe, -0.03em 0 #b4d5fe, 0 0.03em #b4d5fe, 279 | 0 -0.03em #b4d5fe, 0.06em 0 #b4d5fe, -0.06em 0 #b4d5fe, 0.09em 0 #b4d5fe, 280 | -0.09em 0 #b4d5fe, 0.12em 0 #b4d5fe, -0.12em 0 #b4d5fe, 0.15em 0 #b4d5fe, 281 | -0.15em 0 #b4d5fe; 282 | background: #b4d5fe; 283 | } 284 | 285 | /* Sidenotes, margin notes, figures, captions */ 286 | img { 287 | max-width: 100%; 288 | } 289 | 290 | .sidenote, 291 | .marginnote { 292 | float: right; 293 | clear: right; 294 | margin-right: -60%; 295 | width: 50%; 296 | margin-top: 0; 297 | margin-bottom: 0; 298 | font-size: 1.1rem; 299 | line-height: 1.3; 300 | vertical-align: baseline; 301 | position: relative; 302 | } 303 | 304 | .sidenote-number { 305 | counter-increment: sidenote-counter; 306 | } 307 | 308 | .sidenote-number:after, 309 | .sidenote:before { 310 | content: counter(sidenote-counter) " "; 311 | font-family: et-book-roman-old-style; 312 | position: relative; 313 | vertical-align: baseline; 314 | } 315 | 316 | .sidenote-number:after { 317 | content: counter(sidenote-counter); 318 | font-size: 1rem; 319 | top: -0.5rem; 320 | left: 0.1rem; 321 | } 322 | 323 | .sidenote:before { 324 | content: counter(sidenote-counter) " "; 325 | top: -0.5rem; 326 | } 327 | 328 | blockquote .sidenote, 329 | blockquote .marginnote { 330 | margin-right: -82%; 331 | min-width: 59%; 332 | text-align: left; 333 | } 334 | 335 | p, 336 | footer, 337 | table { 338 | width: 55%; 339 | } 340 | 341 | div.fullwidth, 342 | table.fullwidth { 343 | width: 100%; 344 | } 345 | 346 | div.table-wrapper { 347 | overflow-x: auto; 348 | font-family: "Trebuchet MS", "Gill Sans", "Gill Sans MT", sans-serif; 349 | } 350 | 351 | .sans { 352 | font-family: "Gill Sans", "Gill Sans MT", Calibri, sans-serif; 353 | letter-spacing: 0.03em; 354 | } 355 | 356 | code { 357 | font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; 358 | font-size: 1rem; 359 | line-height: 1.42; 360 | } 361 | 362 | .sans > code { 363 | font-size: 1.2rem; 364 | } 365 | 366 | h1 > code, 367 | h2 > code, 368 | h3 > code { 369 | font-size: 0.8em; 370 | } 371 | 372 | .marginnote > code, 373 | .sidenote > code { 374 | font-size: 1rem; 375 | } 376 | 377 | pre.code { 378 | font-size: 0.9rem; 379 | width: 52.5%; 380 | margin-left: 2.5%; 381 | overflow-x: auto; 382 | } 383 | 384 | pre.code.fullwidth { 385 | width: 90%; 386 | } 387 | 388 | .fullwidth { 389 | max-width: 90%; 390 | clear: both; 391 | } 392 | 393 | span.newthought { 394 | font-variant: small-caps; 395 | font-size: 1.2em; 396 | } 397 | 398 | input.margin-toggle { 399 | display: none; 400 | } 401 | 402 | label.sidenote-number { 403 | display: inline; 404 | } 405 | 406 | label.margin-toggle:not(.sidenote-number) { 407 | display: none; 408 | } 409 | 410 | @media (max-width: 760px) { 411 | body { 412 | width: 84%; 413 | padding-left: 8%; 414 | padding-right: 8%; 415 | } 416 | p, 417 | footer { 418 | width: 100%; 419 | } 420 | pre.code { 421 | width: 97%; 422 | } 423 | ul { 424 | width: 85%; 425 | } 426 | figure { 427 | max-width: 90%; 428 | } 429 | figcaption, 430 | figure.fullwidth figcaption { 431 | margin-right: 0%; 432 | max-width: none; 433 | } 434 | blockquote { 435 | margin-left: 1.5em; 436 | margin-right: 0em; 437 | } 438 | blockquote p, 439 | blockquote footer { 440 | width: 100%; 441 | } 442 | label.margin-toggle:not(.sidenote-number) { 443 | display: inline; 444 | } 445 | .sidenote, 446 | .marginnote { 447 | display: none; 448 | } 449 | .margin-toggle:checked + .sidenote, 450 | .margin-toggle:checked + .marginnote { 451 | display: block; 452 | float: left; 453 | left: 1rem; 454 | clear: both; 455 | width: 95%; 456 | margin: 1rem 2.5%; 457 | vertical-align: baseline; 458 | position: relative; 459 | } 460 | label { 461 | cursor: pointer; 462 | } 463 | div.table-wrapper, 464 | table { 465 | width: 85%; 466 | } 467 | img { 468 | width: 100%; 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /docs/assets/zenburn.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Zenburn style from voldmar.ru (c) Vladimir Epifanov 4 | based on dark.css by Ivan Sagalaev 5 | 6 | */ 7 | 8 | .hljs { 9 | display: block; 10 | overflow-x: auto; 11 | padding: 0.5em; 12 | background: #3f3f3f; 13 | color: #dcdcdc; 14 | } 15 | 16 | .hljs-keyword, 17 | .hljs-selector-tag, 18 | .hljs-tag { 19 | color: #e3ceab; 20 | } 21 | 22 | .hljs-template-tag { 23 | color: #dcdcdc; 24 | } 25 | 26 | .hljs-number { 27 | color: #8cd0d3; 28 | } 29 | 30 | .hljs-variable, 31 | .hljs-template-variable, 32 | .hljs-attribute { 33 | color: #efdcbc; 34 | } 35 | 36 | .hljs-literal { 37 | color: #efefaf; 38 | } 39 | 40 | .hljs-subst { 41 | color: #8f8f8f; 42 | } 43 | 44 | .hljs-title, 45 | .hljs-name, 46 | .hljs-selector-id, 47 | .hljs-selector-class, 48 | .hljs-section, 49 | .hljs-type { 50 | color: #efef8f; 51 | } 52 | 53 | .hljs-symbol, 54 | .hljs-bullet, 55 | .hljs-link { 56 | color: #dca3a3; 57 | } 58 | 59 | .hljs-deletion, 60 | .hljs-string, 61 | .hljs-built_in, 62 | .hljs-builtin-name { 63 | color: #cc9393; 64 | } 65 | 66 | .hljs-addition, 67 | .hljs-comment, 68 | .hljs-quote, 69 | .hljs-meta { 70 | color: #7f9f7f; 71 | } 72 | 73 | .hljs-emphasis { 74 | font-style: italic; 75 | } 76 | 77 | .hljs-strong { 78 | font-weight: bold; 79 | } 80 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 24 | 25 | 26 | 27 | Fork me on GitHub 33 | 34 |
35 |
37 |

Routedux — Routes the Redux Way

38 |

39 | Route Dux 44 |

45 |

46 | 49 | Build Status 54 |

55 |

Routedux routes URLs to Redux actions and vice versa.

56 |

57 | Your application doesn't need to know it lives in a browser, but 58 | your users want pretty urls and deep links. 59 |

60 |
61 |

62 | Wait, my application doesn't need to know it lives in a browser? 63 |

64 |

65 | URLs are great for finding things on the internet. But a single 66 | page application is not the same as a collection of resources that 67 | lives on a remote server. 68 |

69 |

70 | A single page application is a web application only in the sense 71 | that it lives on the web. URLs are are not essential to it working 72 | well. 73 |

74 |

75 | URLs give users accessing your application in a browser the 76 | ability to bookmark a particular view in your application so that 77 | their expectation of browser-based applications will continue to 78 | work. 79 |

80 |

81 | We think that's a good thing, but we also don't think the idea of 82 | url paths should be littered through your application. 83 |

84 |

85 | When you are developing a redux application, you want your UI to 86 | be a pure function of the current state tree. 87 |

88 |

89 | By adding routes to that, it makes it harder to test. And this 90 | difficulty can be compounded by other decisions about how to add 91 | routes to your application. 92 |

93 |
94 |
95 |

An alternative approach

96 |

97 | React Router is the currently-accepted way to do URL routing in 98 | React applications. For a standard React application without 99 | Redux, this solution isn't too bad. But once you add Redux, things 100 | get difficult. 101 |

102 |

103 | We basically discovered the same lessons as Formidable 104 | Labs:React Router is the wrong way to route in Redux apps. 120 | However, we don't think their solution (redux-little-router) goes far enough, as it still embeds the idea of routes 124 | throughout your user interface. 125 |

126 |

127 | Once you separate URLs from your application state, you can easily 128 | port it to other environments that don't know what URLs are, and 129 | by simply removing the routing declaration, things will work as 130 | before. 131 |

132 |

133 | As an added (and we think absolutely essential) benefit, your 134 | entire application becomes easier to test, as rendering is a pure 135 | function of Redux state, and model logic and route actions are 136 | entirely encapsulated in Redux outside of the app. 137 |

138 |
139 |
140 |

Demo Site

141 |

142 | We have a demo codebase at 143 | demo repository. 146 |

147 |
148 |
149 |

Simple Routing in 25 lines

150 |

import installBrowserRouter from 'routedux'; 151 | import {createStore, compose} from 'redux';

const LOAD_USER = 'LOAD_USER';

function currentUserId() { 152 | return 42; 153 | };

function reduce(state = initialState(), action) { 154 | ... 155 | }

const routesConfig = [ 156 | ['/user/:id', LOAD_USER, {}], 157 | ['/user/me', LOAD_USER, {id: currentUserId()}], 158 | ['/article/:slug', 'LOAD_ARTICLE', {}], 159 | ['/', 'LOAD_ARTICLE', {slug: "home-content"}] 160 | ];

const {enhancer} = installBrowserRouter(routesConfig);

const store = createStore(reduce, compose( 161 | enhancer 162 | ));

163 |

164 | Any time a handled action fires the url in the address bar will 165 | change, and if the url in the address bar changes the 166 | corresponding action will fire (unless the action was initiated by 167 | a url change). 168 |

169 |
170 |
171 |

Route matching precedence - which route matches best?

172 |

173 | Route precedence is a function of the type of matching done in 174 | each segment and the order in which the wildcard segments match. 175 | Exact matches are always preferred to wildcards moving from left 176 | to right. 177 |

178 |
const routesInOrderOfPrecedence = [
179 |   ['/user/me/update', '/user/me'], // both perfectly specific - will match above any wildcard route
180 |   '/user/me/:view',
181 |   '/user/:id/update', // less specific because 'me' is exact match, while :id is a wildcard
182 |   '/user/:id/:view'
183 | ];
184 |
185 |
186 |

Fragment component

187 |

188 | Given that every UI state will be in your state tree as a function 189 | of your reducer logic, you can express any restriction on which 190 | parts of the UI display, even those that have nothing to do with 191 | the specific transformations caused by your URL actions. 192 |

193 |

const state = { 194 | menu: ... 195 | }

const view = ( 196 | <PageFrame> 197 | <Fragment state={state} filterOn="menu"> 198 | <Menu /> 199 | </Fragment> 200 | </PageFrame> 201 | )

// If menu is truthy, this renders as: 202 | ( 203 | <PageFrame> 204 | <Menu /> 205 | </PageFrame> 206 | )

// If menu is falsy, this renders as: 207 | ( 208 | <PageFrame> 209 | </PageFrame> 210 | )

// If property is missing in path, it's falsy. 211 | const view = ( 212 | <PageFrame> 213 | <Fragment state={state} filterOn="menu.missingProp.something"> 214 | <Menu /> 215 | </Fragment> 216 | </PageFrame> 217 | )

// Renders as: 218 | ( 219 | <PageFrame> 220 | </PageFrame> 221 | )

222 |
223 |
224 |

ActionLink and pathForAction(action)

225 |

226 | Occasionally it is nice to render URLs inside of your application. 227 |

228 |

229 | As a convenience, we have attached 230 | pathForAction to the 231 | store object, which uses the same 232 | matcher that the action matcher uses. This allows you to create 233 | links in your application by using the actions. 234 |

235 |

const routesConfig = [ 236 | ['/user/:id', LOAD_USER, {}], 237 | ['/user/me', LOAD_USER, {id: currentUserId()}] 238 | ]; 239 | /* ... do store initialization */

store.pathForAction({type:LOAD_USER, id: currentUserId()}); 240 | /* returns /user/me */

/* ActionLink */

import { ActionLink as _ActionLink } from "routedux";

const store = createStore(...);

class ActionLink extends _ActionLink { 241 | constructor(props) { 242 | super({ ...props }); 243 | this.store = store; 244 | } 245 | }

const action = { 246 | type: LOAD_USER, 247 | id: 123 248 | };

return ( 249 | <ActionLink action={action}>Link Text</ActionLink> 250 | );

/* renders as a link to <a href="/usr/123">Link Text</a> with the text */

251 |
252 |

253 | Now you have links, but your links always stay up to date with your 254 | routing configuration. 255 |

256 |
257 |
258 |
259 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /docs/index.html.pm: -------------------------------------------------------------------------------- 1 | #lang pollen 2 | 3 | ◊section[#:headline "Routedux — Routes the Redux Way"]{ 4 | 5 | ◊img[#:alt "Route Dux" #:src "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Ducks_crossing_the_road_sign.png/92px-Ducks_crossing_the_road_sign.png" #:align "right"] 6 | 7 | ◊a[#:href "https://badge.fury.io/js/routedux"]{◊img[#:src "https://badge.fury.io/js/routedux.svg"]} 8 | ◊a[#:href "https://travis-ci.org/cjdev/routedux"]{◊img[#:alt "Build Status" #:src "https://travis-ci.org/cjdev/routedux.svg?branch=master"]} 9 | 10 | Routedux routes URLs to Redux actions and vice versa. 11 | 12 | Your application doesn't need to know it lives in a browser, but your 13 | users want pretty urls and deep links. 14 | 15 | ◊section[#:headline "Wait, my application doesn't need to know it lives in a browser?"]{ 16 | 17 | URLs are great for finding things on the internet. But a single page 18 | application is not the same as a collection of resources that lives on 19 | a remote server. 20 | 21 | A single page application is a web application only in the sense that 22 | it lives on the web. URLs are are not essential to it working well. 23 | 24 | URLs give users accessing your application in a browser the ability to 25 | bookmark a particular view in your application so that their 26 | expectation of browser-based applications will continue to work. 27 | 28 | We think that's a good thing, but we also don't think the idea of url 29 | paths should be littered through your application. 30 | 31 | When you are developing a redux application, you want your UI to be a 32 | pure function of the current state tree. 33 | 34 | By adding routes to that, it makes it harder to test. And this 35 | difficulty can be compounded by other decisions about how 36 | to add routes to your application. 37 | } 38 | 39 | ◊section[#:headline "An alternative approach"]{ 40 | 41 | React Router is the currently-accepted way to do URL routing in React 42 | applications. For a standard React application without Redux, this 43 | solution isn't too bad. But once you add Redux, things get difficult. 44 | 45 | We basically discovered the same lessons as Formidable Labs:◊sidenote["REACTROUTERWRONG"]{ 46 | ◊a[#:href 47 | "http://formidable.com/blog/2016/07/11/let-the-url-do-the-talking-part-1-the-pain-of-react-router-in-redux/"]{React 48 | Router is the wrong way to route in Redux apps.}} However, we don't 49 | think their solution (◊a[#:href 50 | "https://github.com/FormidableLabs/redux-little-router"]{redux-little-router}) 51 | goes far enough, as it still embeds the idea of routes throughout your 52 | user interface. 53 | 54 | Once you separate URLs from your application state, you can easily 55 | port it to other environments that don't know what URLs are, and by 56 | simply removing the routing declaration, things will work as before. 57 | 58 | As an added (and we think absolutely essential) benefit, your entire 59 | application becomes easier to test, as rendering is a pure function of 60 | Redux state, and model logic and route actions are entirely 61 | encapsulated in Redux outside of the app.} 62 | 63 | ◊section[#:headline "Demo Site"]{ 64 | We have a demo codebase at ◊a[#:href "https://github.com/cjdev/routedux-docs-demo"]{demo repository}. 65 | 66 | }◊section[#:headline "Simple Routing in 25 lines"]{ 67 | ◊pre{ 68 | ◊code[#:class "javascript"]{ 69 | import installBrowserRouter from 'routedux'; 70 | import {createStore, compose} from 'redux'; 71 | 72 | const LOAD_USER = 'LOAD_USER'; 73 | 74 | function currentUserId() { 75 | return 42; 76 | }; 77 | 78 | function reduce(state = initialState(), action) { 79 | ... 80 | } 81 | 82 | const routesConfig = [ 83 | ['/user/:id', LOAD_USER, {}], 84 | ['/user/me', LOAD_USER, {id: currentUserId()}], 85 | ['/article/:slug', 'LOAD_ARTICLE', {}], 86 | ['/', 'LOAD_ARTICLE', {slug: "home-content"}] 87 | ]; 88 | 89 | const {enhancer} = installBrowserRouter(routesConfig); 90 | 91 | const store = createStore(reduce, compose( 92 | enhancer 93 | )); 94 | }} 95 | 96 | Any time a handled action fires the url in the address bar will 97 | change, and if the url in the address bar changes the corresponding 98 | action will fire (unless the action was initiated by a url change). 99 | } 100 | 101 | ◊section[#:headline "Route matching precedence - which route matches best?"]{ 102 | Route precedence is a function of the type of matching done in each 103 | segment and the order in which the wildcard segments match. Exact 104 | matches are always preferred to wildcards moving from left to right. 105 | 106 | ◊pre{ 107 | ◊code[#:class "javascript"]{ 108 | const routesInOrderOfPrecedence = [ 109 | ['/user/me/update', '/user/me'], // both perfectly specific - will match above any wildcard route 110 | '/user/me/:view', 111 | '/user/:id/update', // less specific because 'me' is exact match, while :id is a wildcard 112 | '/user/:id/:view' 113 | ]; 114 | }} 115 | } 116 | 117 | ◊section[#:headline "Fragment component"]{ 118 | Given that every UI state will be in your state tree as a function of 119 | your reducer logic, you can express any restriction on which parts of 120 | the UI display, even those that have nothing to do with the specific 121 | transformations caused by your URL actions. 122 | 123 | ◊pre{ 124 | ◊code[#:class "javascript"]{ 125 | const state = { 126 | menu: ... 127 | } 128 | 129 | const view = ( 130 | 131 | 132 | 133 | 134 | 135 | ) 136 | 137 | // If menu is truthy, this renders as: 138 | ( 139 | 140 | 141 | 142 | ) 143 | 144 | // If menu is falsy, this renders as: 145 | ( 146 | 147 | 148 | ) 149 | 150 | // If property is missing in path, it's falsy. 151 | const view = ( 152 | 153 | 154 | 155 | 156 | 157 | ) 158 | 159 | // Renders as: 160 | ( 161 | 162 | 163 | ) 164 | }} 165 | } 166 | 167 | ◊section[#:headline "ActionLink and pathForAction(action)"]{ 168 | Occasionally it is nice to render URLs inside of your application. 169 | 170 | As a convenience, we have attached ◊code[#:class "javascript"]{pathForAction} 171 | to the ◊code[#:class "javascript"]{store} object, which uses the same 172 | matcher that the action matcher uses. This allows you to create links 173 | in your application by using the actions. 174 | 175 | ◊pre{ 176 | ◊code[#:class "javascript"]{ 177 | const routesConfig = [ 178 | ['/user/:id', LOAD_USER, {}], 179 | ['/user/me', LOAD_USER, {id: currentUserId()}] 180 | ]; 181 | /* ... do store initialization */ 182 | 183 | store.pathForAction({type:LOAD_USER, id: currentUserId()}); 184 | /* returns /user/me */ 185 | 186 | /* ActionLink */ 187 | 188 | import { ActionLink as _ActionLink } from "routedux"; 189 | 190 | const store = createStore(...); 191 | 192 | class ActionLink extends _ActionLink { 193 | constructor(props) { 194 | super({ ...props }); 195 | this.store = store; 196 | } 197 | } 198 | 199 | const action = { 200 | type: LOAD_USER, 201 | id: 123 202 | }; 203 | 204 | return ( 205 | Link Text 206 | ); 207 | 208 | /* renders as a link to Link Text with the text */ 209 | }} 210 | } 211 | 212 | Now you have links, but your links always stay up to date with your 213 | routing configuration. 214 | } 215 | -------------------------------------------------------------------------------- /docs/pollen.rkt: -------------------------------------------------------------------------------- 1 | #lang racket 2 | 3 | (require pollen/tag compatibility/defmacro) 4 | (require pollen/decode txexpr srfi/48) 5 | (provide (all-defined-out)) 6 | 7 | (defmacro defun (name args . body) 8 | `(define (,name ,@args) 9 | ,@body)) 10 | 11 | (defmacro funcall (fun . args) 12 | `(,fun ,@args)) 13 | 14 | (defmacro defvar (name def) 15 | `(define ,name ,def)) 16 | 17 | ;; This is a macro so that the parameterize form evaluates in the right order 18 | (define-syntax section 19 | (syntax-rules () 20 | [(section #:headline %headline . body) 21 | (parameterize ([head-tag-num (1+ (head-tag-num))]) 22 | (section `,(headline %headline) . body))] 23 | [(section . body) 24 | (txexpr 'section empty 25 | (decode-elements (list . body) 26 | #:txexpr-elements-proc 27 | (lambda (x) 28 | (decode-paragraphs x 29 | #:linebreak-proc 30 | (lambda (y) y)))))])) 31 | 32 | (defvar head-tag-num 33 | (make-parameter 0)) 34 | 35 | (defvar def 36 | (default-tag-function 'a #:name "foo")) 37 | 38 | (defvar items 39 | (default-tag-function 'ul)) 40 | 41 | (defvar item 42 | (default-tag-function 'li 'p)) 43 | 44 | (defvar %section-tag 45 | (default-tag-function 'section)) 46 | 47 | (defun 1+ (n) 48 | (+ 1 n)) 49 | 50 | (defun current-head-tag () 51 | (string->symbol (format "h~d" (head-tag-num)))) 52 | 53 | (defun headline (element) 54 | (funcall (default-tag-function (current-head-tag)) empty element)) 55 | 56 | (defun term (val) 57 | (let ([code (default-tag-function 'code)] 58 | [u (default-tag-function 'u)] 59 | [a (default-tag-function 'a)]) 60 | (code (u (a #:href (string-append "#" val) 61 | val))))) 62 | 63 | (defun link (url text) 64 | `(a (funcall (href ,url)) ,text)) 65 | 66 | (defun sidenote (label . xs) 67 | `(splice-me 68 | (label ((for ,label) (class "margin-toggle sidenote-number"))) 69 | (input ((id ,label) (class "margin-toggle")(type "checkbox"))) 70 | (span ((class "sidenote")) ,@xs))) 71 | -------------------------------------------------------------------------------- /docs/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 24 | 25 | 26 | 27 | Fork me on GitHub 33 | 34 |
35 | ◊(->html ◊doc) 36 |
37 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "routedux", 3 | "contributors": [ 4 | { 5 | "name": "Maximilian Summe", 6 | "email": "msumme@gmail.com" 7 | }, 8 | { 9 | "name": "Edward Langley", 10 | "email": "el-os@elangley.org" 11 | } 12 | ], 13 | "main": "dist/index.js", 14 | "version": "1.3.0", 15 | "license": "MIT", 16 | "peerDependencies": { 17 | "react": "^16.8.2", 18 | "react-dom": "^16.12.0", 19 | "react-redux": "^7.0.3", 20 | "redux": "^4.0.4" 21 | }, 22 | "dependencies": { 23 | "ramda": "^0.27.0" 24 | }, 25 | "devDependencies": { 26 | "@babel/cli": "^7.10.4", 27 | "@babel/core": "^7.10.4", 28 | "@babel/plugin-proposal-class-properties": "^7.10.4", 29 | "@babel/plugin-proposal-json-strings": "^7.10.4", 30 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 31 | "@babel/plugin-syntax-import-meta": "^7.10.4", 32 | "@babel/plugin-transform-react-jsx": "^7.10.4", 33 | "@babel/preset-env": "^7.10.4", 34 | "@babel/preset-react": "^7.10.4", 35 | "babel-core": "7.0.0-bridge.0", 36 | "babel-eslint": "10.1.0", 37 | "babel-jest": "^26.1.0", 38 | "dotenv": "8.2.0", 39 | "dotenv-expand": "5.1.0", 40 | "enzyme": "^3.10.0", 41 | "enzyme-adapter-react-16": "^1.14.0", 42 | "enzyme-to-json": "^3.4.4", 43 | "eslint": "^7.4.0", 44 | "eslint-plugin-import": "^2.22.0", 45 | "eslint-plugin-react": "^7.20.3", 46 | "fs-extra": "9.0.1", 47 | "jest": "^26.1.0", 48 | "jest-pnp-resolver": "1.2.2", 49 | "jest-resolve": "26.1.0", 50 | "jest-watch-typeahead": "^0.6.0", 51 | "prettier": "^2.0.5", 52 | "prop-types": "^15.5.10", 53 | "react": "^16.12.0", 54 | "react-app-polyfill": "^1.0.4", 55 | "react-dom": "^16.12.0", 56 | "redux": "^4.0.4" 57 | }, 58 | "scripts": { 59 | "build": "npx babel src -d dist", 60 | "watch": "npx babel -w src -d dist", 61 | "test": "node scripts/test.js --env=jsdom", 62 | "buildPub": "yarn build && npm publish", 63 | "lint": "eslint src", 64 | "format": "prettier --write \"$(git rev-parse --show-toplevel)\"" 65 | }, 66 | "npmFileMap": [ 67 | { 68 | "basePath": "/dist/", 69 | "files": [ 70 | "*.js" 71 | ] 72 | } 73 | ], 74 | "babel": { 75 | "presets": [ 76 | "@babel/preset-react", 77 | "@babel/preset-env" 78 | ] 79 | }, 80 | "jest": { 81 | "testEnvironment": "jsdom", 82 | "testURL": "http://localhost", 83 | "collectCoverageFrom": [ 84 | "src/**/*.js", 85 | "!src/**/*.d.ts" 86 | ], 87 | "testMatch": [ 88 | "/src/**/tests/*.test.js" 89 | ], 90 | "transformIgnorePatterns": [ 91 | "[/\\\\]node_modules[/\\\\].+\\.js$" 92 | ], 93 | "moduleFileExtensions": [ 94 | "js", 95 | "json" 96 | ] 97 | }, 98 | "eslintConfig": { 99 | "extends": [ 100 | "eslint:recommended", 101 | "plugin:react/recommended", 102 | "plugin:import/errors", 103 | "plugin:import/warnings" 104 | ], 105 | "env": { 106 | "browser": true, 107 | "node": true, 108 | "jest": true, 109 | "es6": true 110 | }, 111 | "parserOptions": { 112 | "ecmaVersion": 2018, 113 | "sourceType": "module" 114 | }, 115 | "settings": { 116 | "react": { 117 | "version": "16.0" 118 | } 119 | }, 120 | "rules": { 121 | "no-unused-vars": [ 122 | "error", 123 | { 124 | "argsIgnorePattern": "(^[_][_]*$)|(^.$)", 125 | "varsIgnorePattern": "(^[_][_]*$)|(^R$)" 126 | } 127 | ] 128 | } 129 | }, 130 | "prettier": { 131 | "trailingComma": "es5", 132 | "jsxBracketSameLine": true, 133 | "arrowParens": "avoid" 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /provider-api.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./dist/provider-api"); 2 | -------------------------------------------------------------------------------- /publish.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | version="$1" 9 | 10 | foo="$(mktemp)" 11 | 12 | sedscript=$(printf '/version/s/"[^"]*",/"%s",/' "$version") 13 | 14 | sed "$sedscript" package.json > "$foo"; 15 | 16 | cat "$foo" 17 | grep version "$foo" 18 | 19 | result=y 20 | read -p "Correct [Y/n]? " -r result 21 | 22 | if [[ "${result/Y/y}" != 'y' ]]; then 23 | exit 1; 24 | fi 25 | 26 | mv "$foo" package.json 27 | 28 | git add package.json 29 | 30 | git commit -v 31 | 32 | result=y 33 | read -p "git tag and git push? [Y/n]? " -r result 34 | 35 | if [[ "${result/Y/y}" != 'y' ]]; then 36 | exit 1; 37 | fi 38 | 39 | #npm run buildPub 40 | 41 | git tag "v${version}" 42 | git push git@github.com:cjdev/routedux.git 43 | git push --tags git@github.com:cjdev/routedux.git 44 | 45 | 46 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.BABEL_ENV = "test"; 3 | process.env.NODE_ENV = "test"; 4 | process.env.PUBLIC_URL = ""; 5 | 6 | // Makes the script crash on unhandled rejections instead of silently 7 | // ignoring them. In the future, promise rejections that are not handled will 8 | // terminate the Node.js process with a non-zero exit code. 9 | process.on("unhandledRejection", err => { 10 | throw err; 11 | }); 12 | 13 | // Ensure environment variables are read. 14 | require("../config/env"); 15 | 16 | const jest = require("jest"); 17 | const execSync = require("child_process").execSync; 18 | let argv = process.argv.slice(2); 19 | 20 | function isInGitRepository() { 21 | try { 22 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); 23 | return true; 24 | } catch (e) { 25 | return false; 26 | } 27 | } 28 | 29 | // Watch unless on CI, in coverage mode, explicitly adding `--no-watch`, 30 | // or explicitly running all tests 31 | if ( 32 | !process.env.CI && 33 | argv.indexOf("--coverage") === -1 && 34 | argv.indexOf("--no-watch") === -1 && 35 | argv.indexOf("--watchAll") === -1 36 | ) { 37 | // https://github.com/facebook/create-react-app/issues/5210 38 | const hasSourceControl = isInGitRepository(); 39 | argv.push(hasSourceControl ? "--watch" : "--watchAll"); 40 | } 41 | 42 | // Jest doesn't have this option so we'll remove it 43 | if (argv.indexOf("--no-watch") !== -1) { 44 | argv = argv.filter(arg => arg !== "--no-watch"); 45 | } 46 | 47 | jest.run(argv); 48 | -------------------------------------------------------------------------------- /src/action-link.js: -------------------------------------------------------------------------------- 1 | const ActionLink = (React, PropTypes) => { 2 | class ActionLink extends React.Component { 3 | constructor(props) { 4 | super(props); 5 | } 6 | render() { 7 | const { action, children, ...props } = this.props; 8 | 9 | const renderedRoute = this.store.pathForAction(action); 10 | 11 | return ( 12 | { 15 | ev.preventDefault(); 16 | this.store.dispatch(action); 17 | }} 18 | {...props}> 19 | {children} 20 | 21 | ); 22 | } 23 | } 24 | 25 | ActionLink.propTypes = { 26 | action: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), 27 | children: PropTypes.node, 28 | }; 29 | 30 | return ActionLink; 31 | }; 32 | 33 | let OutComponent = ActionLink; 34 | try { 35 | const React = require("react"); 36 | const PropTypes = require("prop-types"); 37 | 38 | OutComponent = ActionLink(React, PropTypes); 39 | } catch (e) { 40 | /* empty */ 41 | } 42 | 43 | export const _internal = { 44 | ActionLink, 45 | }; 46 | 47 | export default OutComponent; 48 | -------------------------------------------------------------------------------- /src/action-router.js: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | 3 | function pathSplit(path) { 4 | return path.split("/"); 5 | } 6 | 7 | function mostSpecificRouteMatch(match1, match2) { 8 | if (!match1) { 9 | return match2; 10 | } 11 | 12 | const paramLength1 = match1.routeParams.length; 13 | const paramLength2 = match2.routeParams.length; 14 | 15 | const findWildcard = R.compose(R.findIndex(isWildcard), pathSplit); 16 | 17 | let result = paramLength1 > paramLength2 ? match2 : match1; 18 | 19 | if (paramLength1 === paramLength2) { 20 | const path1WildcardIdx = findWildcard(match1.path); 21 | const path2WildcardIdx = findWildcard(match2.path); 22 | 23 | result = 24 | path1WildcardIdx !== -1 && path1WildcardIdx < path2WildcardIdx 25 | ? match2 26 | : match1; 27 | } 28 | 29 | if (result === null) { 30 | throw new Error("routes should have been disambiguated at compile time"); 31 | } 32 | 33 | return result; 34 | } 35 | 36 | // do something with routes. 37 | function matchRoute(loc, matchers) { 38 | const inputPath = loc.pathname; 39 | 40 | const buildMatch = (extractedParams, route) => ({ 41 | extractedParams, 42 | ...route, 43 | }); 44 | 45 | return R.reduce( 46 | (match, [_, { type: matcherType, route }]) => { 47 | const { pathMatcher } = route; 48 | const matchedParams = pathMatcher(inputPath); 49 | 50 | if (matchedParams) { 51 | return matcherType === "exact" 52 | ? buildMatch(matchedParams, route) 53 | : mostSpecificRouteMatch(match, buildMatch(matchedParams, route)); 54 | } else { 55 | return match; 56 | } 57 | }, 58 | null, 59 | R.toPairs(matchers) 60 | ); 61 | } 62 | 63 | function mostSpecificActionMatch(match1, match2) { 64 | if (!match1) { 65 | return match2; 66 | } 67 | 68 | const countExtraParams = ({ extraParams: obj }) => R.keys(obj).length; 69 | return countExtraParams(match1) >= countExtraParams(match2) ? match1 : match2; 70 | } 71 | 72 | // matchers is {action : [pathMatcher]} structure 73 | function matchAction(action, matchers) { 74 | // match on params in action vs possible actions if more than 1 75 | let match = null; 76 | 77 | const { type: actionType, ...args } = action; 78 | const routes = matchers[actionType]; 79 | 80 | // Specificity: 81 | // 1. wildcard(s) / no extra param /route/:id || /route/me 82 | // 2. wildcards / exact extra params match with remaining wildcard 83 | // 3. no-wildcard / exact extra params match 84 | 85 | for (const { type: matcherType, route } of routes) { 86 | if (matcherType === "exact" && R.equals(route.extraParams, args)) { 87 | // case 3 88 | match = { extractedParams: {}, ...route }; 89 | break; // most specific 90 | } else if (matcherType === "wildcard") { 91 | // case 1+2 92 | 93 | const unallocatedArgKeys = R.difference( 94 | R.keys(args), 95 | R.keys(route.extraParams) 96 | ); 97 | // if all keys ^ are equal to all keys in route 98 | const intersectCount = R.intersection( 99 | unallocatedArgKeys, 100 | route.routeParams 101 | ).length; 102 | const unionCount = R.union(unallocatedArgKeys, route.routeParams).length; 103 | 104 | if (intersectCount === unionCount) { 105 | const extractedParams = R.pick(unallocatedArgKeys, args); 106 | match = mostSpecificActionMatch(match, { extractedParams, ...route }); 107 | } 108 | } 109 | } 110 | 111 | return match; 112 | } 113 | 114 | function matchesAction(action, matchers) { 115 | return !!matchers[action.type]; 116 | } 117 | 118 | function isWildcard(segment) { 119 | return segment && segment[0] === ":"; 120 | } 121 | 122 | function extractParams(path) { 123 | const pathParts = path.split("/"); 124 | 125 | const params = R.compose( 126 | R.map(x => x.substr(1)), 127 | R.filter(isWildcard) 128 | )(pathParts); 129 | 130 | if (R.uniq(params).length !== params.length) { 131 | throw new Error("duplicate param"); 132 | } 133 | 134 | return params; 135 | } 136 | 137 | function normalizePathParts(path) { 138 | const splitAndFilterEmpty = R.compose( 139 | R.filter(p => p !== ""), 140 | R.split("/") 141 | ); 142 | 143 | return splitAndFilterEmpty(path); 144 | } 145 | 146 | function makeRoute(path, action, extraParams) { 147 | const type = R.includes(":", path) ? "wildcard" : "exact"; 148 | 149 | const normalizedPathParts = normalizePathParts(path); 150 | 151 | const pathMatcher = function (inputPath) { 152 | let result = null; 153 | 154 | const normMatchPath = normalizedPathParts; 155 | const normInputPath = normalizePathParts(inputPath); 156 | 157 | // exact match 158 | if (R.equals(normalizedPathParts, normInputPath)) { 159 | return {}; 160 | } 161 | 162 | //wildcard match 163 | const inputLength = normInputPath.length; 164 | const matchLength = normMatchPath.length; 165 | 166 | if (inputLength === matchLength) { 167 | result = R.reduce( 168 | (extractedValues, [match, input]) => { 169 | if (extractedValues === null) { 170 | return null; 171 | } 172 | 173 | if (match === input) { 174 | return extractedValues; 175 | } else if (R.startsWith(":", match)) { 176 | const wildcardName = R.replace(":", "", match); 177 | return { ...extractedValues, [wildcardName]: input }; 178 | } else { 179 | return null; 180 | } 181 | }, 182 | {}, 183 | R.zip(normMatchPath, normInputPath) 184 | ); 185 | } 186 | 187 | return result; 188 | }; 189 | 190 | let routeParams = extractParams(path); 191 | 192 | return { 193 | type, 194 | route: { 195 | pathMatcher, 196 | path, 197 | action, 198 | routeParams, 199 | extraParams, 200 | }, 201 | }; 202 | } 203 | 204 | function normalizeWildcards(path) { 205 | let curIdx = 0; 206 | //todo curIdx doesn't increment 207 | return path.map(el => { 208 | if (isWildcard(el)) { 209 | return `:wildcard${curIdx}`; 210 | } else { 211 | return el; 212 | } 213 | }); 214 | } 215 | 216 | function routeAlreadyExists(compiledRouteMatchers, path) { 217 | let result = Object.prototype.hasOwnProperty.call( 218 | compiledRouteMatchers, 219 | path 220 | ); 221 | 222 | if (!result) { 223 | const normalizingSplit = R.compose(normalizeWildcards, pathSplit); 224 | const pathParts = normalizingSplit(path); 225 | 226 | for (const otherPath of R.keys(compiledRouteMatchers)) { 227 | const otherPathParts = normalizingSplit(otherPath); 228 | if (R.equals(pathParts, otherPathParts)) { 229 | throw new Error( 230 | `invalid routing configuration — route ${path} overlaps with route ${otherPath}` 231 | ); 232 | } 233 | } 234 | } 235 | 236 | return result; 237 | } 238 | 239 | function compileRoutes(routesConfig) { 240 | const compiledActionMatchers = {}; 241 | const compiledRouteMatchers = {}; 242 | 243 | for (let [path, action, extraParams] of routesConfig) { 244 | if (typeof path !== "string" || typeof action !== "string") { 245 | throw new Error( 246 | "invalid routing configuration - path and action must both be strings" 247 | ); 248 | } 249 | 250 | if (!compiledActionMatchers[action]) { 251 | compiledActionMatchers[action] = []; 252 | } 253 | 254 | const route = makeRoute(path, action, extraParams); 255 | compiledActionMatchers[action].push(route); 256 | 257 | if (routeAlreadyExists(compiledRouteMatchers, path)) { 258 | throw new Error("overlapping paths"); 259 | } 260 | 261 | compiledRouteMatchers[path] = route; 262 | } 263 | return { 264 | compiledActionMatchers, // { ACTION: [Route] } 265 | compiledRouteMatchers, // { PATH: Route } 266 | }; 267 | } 268 | 269 | function constructAction(match) { 270 | return { type: match.action, ...match.extractedParams, ...match.extraParams }; 271 | } 272 | 273 | function constructPath(match) { 274 | const parts = match.path.split("/"); 275 | const resultParts = []; 276 | 277 | for (const part of parts) { 278 | if (part[0] === ":") { 279 | const name = part.slice(1); 280 | const val = Object.prototype.hasOwnProperty.call( 281 | match.extractedParams, 282 | name 283 | ) 284 | ? match.extractedParams[name] 285 | : match.extraParams[name]; 286 | resultParts.push(val); 287 | } else { 288 | resultParts.push(part); 289 | } 290 | } 291 | return resultParts.join("/"); 292 | } 293 | 294 | function createActionDispatcher(routesConfig, _window = window) { 295 | const { compiledActionMatchers, compiledRouteMatchers } = compileRoutes( 296 | routesConfig 297 | ); 298 | 299 | function pathForAction(action) { 300 | const match = matchAction(action, compiledActionMatchers); 301 | return match ? constructPath(match) : null; 302 | } 303 | 304 | function actionForLocation(location) { 305 | const match = matchRoute(location, compiledRouteMatchers); 306 | return match ? constructAction(match) : null; 307 | } 308 | 309 | let actionListeners = []; 310 | let currentPath = null; 311 | let currentAction = null; 312 | 313 | function ifPathChanged(newPath, cb) { 314 | if (currentPath !== newPath) { 315 | currentPath = newPath; 316 | cb(); 317 | } 318 | } 319 | 320 | let initFlag = false; 321 | 322 | const actionDispatcher = { 323 | init() { 324 | if (!initFlag) { 325 | initFlag = true; 326 | this.receiveLocation(_window.location); 327 | } 328 | }, 329 | get currentPath() { 330 | return currentPath; 331 | }, 332 | get currentAction() { 333 | return currentAction; 334 | }, 335 | pathForAction, 336 | 337 | //hook for everything to get action on route change 338 | addActionListener(cb) { 339 | actionListeners.push(cb); 340 | return () => { 341 | const index = R.findIndex(x => x === cb, actionListeners); 342 | actionListeners = R.remove(index, 1, actionListeners); 343 | }; 344 | }, 345 | 346 | //needed for window event listener 347 | handleEvent(ev) { 348 | const location = ev.detail; 349 | this.receiveLocation(location); 350 | }, 351 | 352 | receiveLocation(location) { 353 | ifPathChanged(location.pathname, () => { 354 | const action = actionForLocation(location); 355 | 356 | currentAction = action; 357 | if (action) { 358 | actionListeners.forEach(cb => cb(action)); 359 | } 360 | }); 361 | }, 362 | 363 | // can this be simplified to get rid of fundamental action model? 364 | receiveAction(action, fireCallbacks = false) { 365 | const newPath = matchesAction(action, compiledActionMatchers) 366 | ? pathForAction(action) 367 | : null; 368 | 369 | if (newPath) { 370 | ifPathChanged(newPath, () => { 371 | currentAction = action; 372 | 373 | _window.history.pushState({}, "", newPath); 374 | 375 | if (fireCallbacks) { 376 | actionListeners.forEach(cb => cb(action)); 377 | } 378 | }); 379 | } 380 | }, 381 | }; 382 | 383 | _window.addEventListener("urlchanged", actionDispatcher); 384 | 385 | return actionDispatcher; 386 | } 387 | 388 | export { createActionDispatcher }; 389 | -------------------------------------------------------------------------------- /src/change-url-event.js: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | 3 | function wrapEvent(target, name, obj) { 4 | target.addEventListener(name, obj); 5 | } 6 | 7 | function runOnceFor(object, flag, cb) { 8 | if (!object[flag]) { 9 | object[flag] = true; 10 | cb(); 11 | } 12 | } 13 | 14 | let MISSING_CHANGE_URL = Symbol("missing_change_url"); 15 | export default function addChangeUrlEvent(window) { 16 | runOnceFor(window, MISSING_CHANGE_URL, () => { 17 | const changeUrlEventCreator = { 18 | lastLocation: null, 19 | handleEvent(_) { 20 | // interface for EventListener 21 | 22 | let { hash, host, hostname, origin, href, pathname, port, protocol } = 23 | window.location || {}; 24 | // store in object for comparison 25 | const pushedLocation = { 26 | hash, 27 | host, 28 | hostname, 29 | origin, 30 | href, 31 | pathname, 32 | port, 33 | protocol, 34 | }; 35 | 36 | // only dispatch action when url has actually changed so same link can be clicked repeatedly. 37 | if (!R.equals(pushedLocation, this.lastLocation)) { 38 | var urlChangeEvent = new CustomEvent("urlchanged", { 39 | detail: pushedLocation, 40 | }); 41 | window.dispatchEvent(urlChangeEvent); 42 | this.lastLocation = pushedLocation; 43 | } 44 | }, 45 | }; 46 | 47 | // / make sure we fire urlchanged for these 48 | wrapEvent(window, "popstate", changeUrlEventCreator); 49 | wrapEvent(window, "pushstate", changeUrlEventCreator); 50 | wrapEvent(window, "replacestate", changeUrlEventCreator); 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /src/fragment.js: -------------------------------------------------------------------------------- 1 | export default function Fragment({ state, filterOn, children }) { 2 | let parts = filterOn.split("."); 3 | let cur = parts.reduce((cur, next) => (cur ? cur[next] : cur), state); 4 | 5 | if (cur) { 6 | return children; 7 | } else { 8 | return null; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/history-events.js: -------------------------------------------------------------------------------- 1 | function polyfillCustomEvent() { 2 | if (typeof window.CustomEvent === "function") return false; 3 | 4 | function CustomEvent(event, params) { 5 | params = params || { bubbles: false, cancelable: false, detail: undefined }; 6 | var evt = document.createEvent("CustomEvent"); 7 | evt.initCustomEvent( 8 | event, 9 | params.bubbles, 10 | params.cancelable, 11 | params.detail 12 | ); 13 | return evt; 14 | } 15 | 16 | CustomEvent.prototype = window.Event.prototype; 17 | 18 | window.CustomEvent = CustomEvent; 19 | } 20 | 21 | function runOnceFor(object, flag, cb) { 22 | if (!object[flag]) { 23 | object[flag] = true; 24 | cb(); 25 | } 26 | } 27 | 28 | let MISSING_HISTORY = Symbol("missing_history"); 29 | export default function addMissingHistoryEvents(window, history) { 30 | runOnceFor(history, MISSING_HISTORY, () => { 31 | const pushState = history.pushState.bind(history); 32 | const replaceState = history.replaceState.bind(history); 33 | 34 | polyfillCustomEvent(); 35 | 36 | history.pushState = function (state, title, url) { 37 | let result = pushState(...arguments); 38 | 39 | var pushstate = new CustomEvent("pushstate", { 40 | detail: { state, title, url }, 41 | }); 42 | window.dispatchEvent(pushstate); 43 | return result; 44 | }; 45 | 46 | history.replaceState = function (state, title, url) { 47 | const result = replaceState(...arguments); 48 | 49 | var replacestate = new CustomEvent("replacestate", { 50 | detail: { state, title, url }, 51 | }); 52 | window.dispatchEvent(replacestate); 53 | return result; 54 | }; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import addMissingHistoryEvents from "./history-events"; 2 | import addChangeUrlEvent from "./change-url-event"; 3 | import installBrowserRouter from "./redux-api"; 4 | import Fragment from "./fragment"; 5 | import ActionLink from "./action-link"; 6 | import { createActionDispatcher } from "./action-router"; 7 | import { createRouteDispatcher } from "./route-dispatcher"; 8 | 9 | addMissingHistoryEvents(window, window.history); 10 | addChangeUrlEvent(window); 11 | 12 | export { 13 | installBrowserRouter, 14 | Fragment, 15 | ActionLink, 16 | createActionDispatcher, 17 | createRouteDispatcher 18 | }; 19 | -------------------------------------------------------------------------------- /src/provider-api.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useEffect } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const RouteContext = React.createContext(null); 5 | const ActionDispatcherContext = React.createContext(null); 6 | 7 | function RouteProvider({ children, routeDispatcher }) { 8 | const [route, updateRoute] = useReducer((state, route) => route, {}); 9 | 10 | useEffect(() => { 11 | return routeDispatcher.addRouteListener(updateRoute); 12 | }); 13 | 14 | useEffect(() => { 15 | routeDispatcher.init(); 16 | }, []); 17 | 18 | return ( 19 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | RouteProvider.propTypes = { 26 | children: PropTypes.oneOfType([ 27 | PropTypes.arrayOf(PropTypes.node), 28 | PropTypes.node, 29 | ]), 30 | routeDispatcher: PropTypes.object, 31 | }; 32 | 33 | function getDisplayName(WrappedComponent) { 34 | return WrappedComponent.displayName || WrappedComponent.name || "Component"; 35 | } 36 | 37 | function withRoute(Component) { 38 | const routeAdded = ({ children, ...restProps }) => { 39 | return ( 40 | 41 | {route => ( 42 | {children} 43 | )} 44 | 45 | ); 46 | }; 47 | routeAdded.displayName = `withRoute(${getDisplayName(Component)})`; 48 | routeAdded.propTypes = { 49 | children: PropTypes.oneOfType([ 50 | PropTypes.arrayOf(PropTypes.node), 51 | PropTypes.node, 52 | ]), 53 | }; 54 | return routeAdded; 55 | } 56 | 57 | function RouteLink({ routeName, params, children, ...props }) { 58 | const action = { 59 | type: routeName, 60 | ...params, 61 | }; 62 | return ( 63 | 64 | {dispatcher => { 65 | const url = dispatcher.pathForAction(action); 66 | return ( 67 | { 69 | ev.preventDefault(); 70 | dispatcher.receiveRoute({ routeName, ...params }, true); 71 | }} 72 | href={url} 73 | {...props}> 74 | {children} 75 | 76 | ); 77 | }} 78 | 79 | ); 80 | } 81 | 82 | RouteLink.propTypes = { 83 | routeName: PropTypes.string, 84 | params: PropTypes.object, 85 | children: PropTypes.oneOfType([ 86 | PropTypes.arrayOf(PropTypes.node), 87 | PropTypes.node, 88 | ]), 89 | }; 90 | 91 | export { RouteProvider, withRoute, RouteLink }; 92 | -------------------------------------------------------------------------------- /src/redux-api.js: -------------------------------------------------------------------------------- 1 | import { createActionDispatcher } from "./action-router"; 2 | 3 | function buildMiddleware(actionDispatcher) { 4 | return _ => next => action => { 5 | actionDispatcher.receiveAction(action); 6 | 7 | return next(action); 8 | }; 9 | } 10 | 11 | function enhanceStoreFactory(actionDispatcher) { 12 | return function enhanceStore(nextStoreCreator) { 13 | const middleware = buildMiddleware(actionDispatcher); 14 | 15 | return (reducer, finalInitialState, enhancer) => { 16 | const theStore = nextStoreCreator(reducer, finalInitialState, enhancer); 17 | 18 | actionDispatcher.addActionListener(action => theStore.dispatch(action)); 19 | 20 | theStore.pathForAction = actionDispatcher.pathForAction.bind( 21 | actionDispatcher 22 | ); 23 | 24 | theStore.dispatch = middleware(theStore)( 25 | theStore.dispatch.bind(theStore) 26 | ); 27 | return theStore; 28 | }; 29 | }; 30 | } 31 | 32 | export default function installBrowserRouter(routesConfig, _window = window) { 33 | const actionDispatcher = createActionDispatcher(routesConfig, _window); 34 | 35 | const middleware = x => { 36 | //eslint-disable-next-line no-console 37 | console.warn( 38 | "Using the routedux middleware directly is deprecated, the enhancer now" + 39 | " applies it automatically and the middleware is now a no-op that" + 40 | " will be removed in later versions." 41 | ); 42 | return y => y; 43 | }; 44 | 45 | return { 46 | middleware, 47 | enhancer: enhanceStoreFactory(actionDispatcher), 48 | init: actionDispatcher.init.bind(actionDispatcher), 49 | _actionDispatcher: actionDispatcher, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/route-dispatcher.js: -------------------------------------------------------------------------------- 1 | import * as R from "ramda"; 2 | import {createActionDispatcher} from "./action-router"; 3 | 4 | function routeToAction(route) { 5 | return R.omit(["routeName"], R.assoc("type", route.routeName, route)); 6 | } 7 | function actionToRoute(action) { 8 | return R.omit(["type"], R.assoc("routeName", action.type, action)); 9 | } 10 | 11 | export function createRouteDispatcher(routesConfig, _window = window) { 12 | const actionDispatcher = createActionDispatcher(routesConfig, _window); 13 | 14 | actionDispatcher.receiveRoute = route => 15 | actionDispatcher.receiveAction(routeToAction(route), true); 16 | actionDispatcher.addRouteListener = cb => 17 | actionDispatcher.addActionListener(action => cb(actionToRoute(action))); 18 | 19 | Object.defineProperty(actionDispatcher, "currentRoute", { 20 | enumerable: true, 21 | get: function () { 22 | return actionToRoute(this.currentAction); 23 | }, 24 | }); 25 | 26 | return actionDispatcher; 27 | } 28 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/action-link.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`additional props are passed through 1`] = ` 4 | 26 | 49 | Hello World! 50 | 51 | 52 | `; 53 | 54 | exports[`renders the url calculated by our internal function 1`] = ` 55 | 76 | 98 | Hello World! 99 | 100 | 101 | `; 102 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/fragment.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should display when state is truthy 1`] = ` 4 |
5 | Hello 6 |
7 | `; 8 | 9 | exports[`should handle arrays in the state tree 1`] = ` 10 |
11 | Hello 12 |
13 | `; 14 | 15 | exports[`should handle paths in the state tree 1`] = ` 16 |
17 | Hello 18 |
19 | `; 20 | -------------------------------------------------------------------------------- /src/tests/action-link.test.js: -------------------------------------------------------------------------------- 1 | import { _internal } from "../action-link"; 2 | import Enzyme, { mount } from "enzyme"; 3 | import ezJson from "enzyme-to-json"; 4 | import Adapter from "enzyme-adapter-react-16"; 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | 7 | import React from "react"; 8 | import PropTypes from "prop-types"; 9 | 10 | const _Link = _internal.ActionLink(React, PropTypes); 11 | 12 | it("dispatches an action on click", () => { 13 | // given 14 | const store = { 15 | pathForAction: jest.fn(() => "/my/path"), 16 | dispatch: jest.fn(), 17 | }; 18 | const props = { 19 | action: { type: "ACTION", id: "123" }, 20 | children: "Hello World!", 21 | }; 22 | class Link extends _Link { 23 | constructor() { 24 | super(); 25 | this.store = store; 26 | } 27 | } 28 | 29 | const wrapper = mount(); 30 | // when 31 | wrapper.simulate("click"); 32 | 33 | //then 34 | expect(store.pathForAction.mock.calls).toEqual([ 35 | [{ type: "ACTION", id: "123" }], 36 | ]); 37 | expect(store.dispatch.mock.calls).toEqual([[{ type: "ACTION", id: "123" }]]); 38 | }); 39 | 40 | it("renders the url calculated by our internal function", () => { 41 | // given 42 | const store = { 43 | pathForAction: jest.fn(() => "/my/path"), 44 | dispatch: jest.fn(), 45 | }; 46 | const props = { 47 | action: {}, 48 | children: "Hello World!", 49 | }; 50 | 51 | class Link extends _Link { 52 | constructor() { 53 | super(); 54 | this.store = store; 55 | } 56 | } 57 | 58 | const wrapper = mount(); 59 | 60 | expect(ezJson(wrapper)).toMatchSnapshot(); 61 | }); 62 | 63 | it("additional props are passed through", () => { 64 | // given 65 | const store = { 66 | pathForAction: jest.fn(() => "/my/path"), 67 | dispatch: jest.fn(), 68 | }; 69 | const props = { 70 | action: {}, 71 | children: "Hello World!", 72 | className: "foo", 73 | }; 74 | 75 | class Link extends _Link { 76 | constructor() { 77 | super(); 78 | this.store = store; 79 | } 80 | } 81 | 82 | const wrapper = mount(); 83 | 84 | expect(ezJson(wrapper)).toMatchSnapshot(); 85 | }); 86 | -------------------------------------------------------------------------------- /src/tests/action-router.test.js: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | 3 | import installBrowserRouter from "../redux-api"; 4 | import addChangeUrlEvent from "../change-url-event.js"; 5 | import addMissingHistoryEvents from "../history-events.js"; 6 | 7 | import { createFakeWindow, createLocation } from "./test-utils"; 8 | 9 | //eslint-disable-next-line no-console 10 | const console_log = console.log; 11 | //eslint-disable-next-line no-console 12 | console.log = () => {}; 13 | // function with_console(cb) { 14 | // console.log = console_log; 15 | // try { 16 | // cb(); 17 | // } catch (e) { 18 | // console.log = () => {}; 19 | // throw e; 20 | // } 21 | // console.log = () => {}; 22 | // } 23 | 24 | function setupTest(routesConfig, path = "/path/to/thing") { 25 | const window = createFakeWindow(path); 26 | const mockPushState = window.history.pushState; 27 | addMissingHistoryEvents(window, window.history); 28 | addChangeUrlEvent(window, window.history); 29 | 30 | const { enhancer, init, _actionDispatcher } = installBrowserRouter( 31 | routesConfig, 32 | window 33 | ); 34 | const reduce = jest.fn(); 35 | 36 | const store = createStore(reduce, enhancer); 37 | 38 | function urlChanges() { 39 | return mockPushState.mock.calls.map(item => item[2]); 40 | } 41 | 42 | function actionsDispatched() { 43 | return reduce.mock.calls.map(item => item[1]).slice(1); 44 | } 45 | 46 | function fireUrlChange(path) { 47 | window.dispatchEvent( 48 | new CustomEvent("urlchanged", { detail: createLocation(path) }) 49 | ); 50 | } 51 | 52 | return { 53 | store, 54 | reduce, 55 | window, 56 | urlChanges, 57 | actionsDispatched, 58 | fireUrlChange, 59 | init, 60 | _actionDispatcher, 61 | }; 62 | } 63 | 64 | it("router handles exact match in preference to wildcard match", () => { 65 | //given 66 | const actionType = "THE_ACTION"; 67 | const action = { type: actionType, id: 1 }; 68 | const routesConfig = [ 69 | ["/somewhere/:id", actionType, {}], 70 | ["/somewhere", actionType, { id: 1 }], 71 | ]; 72 | const { urlChanges, store } = setupTest(routesConfig); 73 | 74 | // when 75 | store.dispatch(action); 76 | 77 | // then 78 | expect(urlChanges()).toEqual(["/somewhere"]); 79 | }); 80 | 81 | it("router does not dispatch an action from url change that is caused by action dispatch", () => { 82 | //given 83 | const actionType = "THE_ACTION"; 84 | const id = "1"; 85 | const view = "home"; 86 | const action = { type: actionType, id, view }; 87 | const routesConfig = [ 88 | ["/somewhere/:id/:view", actionType, {}], 89 | ["/somewhere/:id/default", actionType, { view: "home" }], 90 | ]; 91 | const { store, actionsDispatched } = setupTest(routesConfig); 92 | 93 | // when 94 | store.dispatch(action); 95 | 96 | // then 97 | expect(actionsDispatched()).toEqual([action]); 98 | }); 99 | 100 | it("popstate doesn't cause a pushstate", () => { 101 | //given 102 | const actionType = "THE_ACTION"; 103 | const routesConfig = [ 104 | ["/somewhere/:id/:view", actionType, {}], 105 | ["/somewhere/:id/default", actionType, { view: "home" }], 106 | ]; 107 | 108 | const { urlChanges, init, window } = setupTest( 109 | routesConfig, 110 | "/somewhere/foo/default" 111 | ); 112 | 113 | init(); 114 | window.history.pushState({}, "", "/somwhere/bar/default"); 115 | 116 | // when 117 | window.dispatchEvent(new CustomEvent("popstate", {})); 118 | 119 | // then 120 | expect(urlChanges().length).toEqual(1); 121 | }); 122 | 123 | it("router handles wildcard with extra args correctly", () => { 124 | //given 125 | const actionType = "THE_ACTION"; 126 | const action = { type: actionType, id: 1, view: "home" }; 127 | const routesConfig = [ 128 | ["/somewhere/:id/:view", actionType, {}], 129 | ["/somewhere/:id/default", actionType, { view: "home" }], 130 | ]; 131 | const { urlChanges, store } = setupTest(routesConfig); 132 | 133 | // when 134 | store.dispatch(action); 135 | 136 | // then 137 | expect(urlChanges()).toEqual(["/somewhere/1/default"]); 138 | }); 139 | 140 | it("router handles wildcard with extraArgs correctly with reverse order", () => { 141 | //given 142 | const actionType = "THE_ACTION"; 143 | const action = { type: actionType, id: 1, view: "home" }; 144 | const routesConfig = [ 145 | ["/somewhere/:id/default", actionType, { view: "home" }], 146 | ["/somewhere/:id/:view", actionType, {}], 147 | ]; 148 | const { urlChanges, store } = setupTest(routesConfig); 149 | 150 | // when 151 | store.dispatch(action); 152 | 153 | // then 154 | expect(urlChanges()).toEqual(["/somewhere/1/default"]); 155 | }); 156 | 157 | it("router handles wildcard without extraArgs correctly", () => { 158 | //given 159 | const actionType = "THE_ACTION"; 160 | const action = { type: actionType, id: 1 }; 161 | const routesConfig = [["/somewhere/:id/default", actionType, {}]]; 162 | const { urlChanges, store } = setupTest(routesConfig); 163 | 164 | // when 165 | store.dispatch(action); 166 | 167 | // then 168 | expect(urlChanges()).toEqual(["/somewhere/1/default"]); 169 | }); 170 | 171 | it("router handles wildcard with no match correctly", () => { 172 | //given 173 | const actionType = "THE_ACTION"; 174 | const action = { type: actionType, foo: 1 }; 175 | const routesConfig = [["/somewhere/:id/default", actionType, {}]]; 176 | const { urlChanges, store } = setupTest(routesConfig); 177 | 178 | // when 179 | store.dispatch(action); 180 | 181 | // then ( no url changes triggered) 182 | expect(urlChanges()).toEqual([]); 183 | }); 184 | 185 | it("router does not match when all args are not accounted for", () => { 186 | //given 187 | const actionType = "THE_ACTION"; 188 | const action = { type: actionType, id: 1, view: "home" }; 189 | const routesConfig = [["/somewhere/:id/default", actionType, {}]]; 190 | const { urlChanges, store } = setupTest(routesConfig); 191 | 192 | // when 193 | store.dispatch(action); 194 | 195 | // then ( no url changes triggered) 196 | expect(urlChanges()).toEqual([]); 197 | }); 198 | 199 | it("router should match non-wildcard route in preference to wildcard route", () => { 200 | // given 201 | const routesConfig = [ 202 | ["/somewhere/:id", "ACTION_NAME", {}], 203 | ["/somewhere/specific", "ACTION_NAME", { id: 1 }], 204 | ]; 205 | const { actionsDispatched, fireUrlChange } = setupTest(routesConfig); 206 | 207 | // when 208 | fireUrlChange("/somewhere/specific"); 209 | 210 | // then 211 | expect(actionsDispatched()).toEqual([{ type: "ACTION_NAME", id: 1 }]); 212 | }); 213 | 214 | it("router should throw on duplicate paths", () => { 215 | // given 216 | const routesConfig = [ 217 | ["/somewhere/:id", "ACTION_NAME", {}], 218 | ["/somewhere/:id", "ACTION_NAME", {}], 219 | ]; 220 | 221 | expect(() => { 222 | setupTest(routesConfig); 223 | }).toThrow(); 224 | }); 225 | 226 | it("router should throw on equally specific routes", () => { 227 | // given 228 | const routesConfig = [ 229 | ["/somewhere/:id", "ACTION_NAME", {}], 230 | ["/somewhere/:specific", "ACTION_NAME", {}], 231 | ]; 232 | 233 | expect(() => { 234 | setupTest(routesConfig); 235 | }).toThrow(); 236 | }); 237 | 238 | it("router should match less-wildcarded routes in preference to more wildcarded routes", () => { 239 | //given 240 | const routesConfig = [ 241 | ["/somewhere/:id/:view/:bar", "ACTION_NAME", {}], 242 | ["/somewhere/:foo/:id/:view/:baz", "ACTION_NAME", {}], 243 | ]; 244 | const { actionsDispatched, fireUrlChange } = setupTest(routesConfig); 245 | 246 | // when 247 | fireUrlChange("/somewhere/specific/etc/bar"); 248 | 249 | // then 250 | expect(actionsDispatched()).toEqual([ 251 | { type: "ACTION_NAME", id: "specific", view: "etc", bar: "bar" }, 252 | ]); 253 | }); 254 | 255 | it("router should propagate matches through non-matching cases", () => { 256 | //given 257 | const routesConfig = [ 258 | ["/somewhere/specific/:view", "ACTION_NAME", { id: 1 }], 259 | ["/somewhere/:id/:view", "ACTION_NAME", {}], 260 | ["/not/a/match", "ACTION_NAME", {}], 261 | ]; 262 | const { actionsDispatched, fireUrlChange } = setupTest(routesConfig); 263 | 264 | // when 265 | fireUrlChange("/somewhere/specific/etc"); 266 | 267 | // then 268 | expect(actionsDispatched()).toEqual([ 269 | { type: "ACTION_NAME", id: 1, view: "etc" }, 270 | ]); 271 | }); 272 | 273 | it("router should give precedence to exact match first in equally-specific routes (/a/:b vs /:a/b)", () => { 274 | // given 275 | const routesConfig = [ 276 | ["/something/:dynamic", "ACTION_NAME", {}], 277 | ["/:dyn/something", "ACTION_NAME", {}], 278 | ]; 279 | const { actionsDispatched, fireUrlChange } = setupTest(routesConfig); 280 | 281 | // when 282 | fireUrlChange("/something/something"); 283 | 284 | // then 285 | expect(actionsDispatched()).toEqual([ 286 | { type: "ACTION_NAME", dynamic: "something" }, 287 | ]); 288 | }); 289 | 290 | it("actionDispatcher keeps track of current action and current path", () => { 291 | // given 292 | const routesConfig = [ 293 | ["/something/:dynamic", "ACTION_NAME", {}], 294 | ["/hi/something", "ACTION_NAME", { dynamic: "foo" }], 295 | ]; 296 | const { fireUrlChange, _actionDispatcher } = setupTest(routesConfig); 297 | 298 | // when 299 | fireUrlChange("/something/something"); 300 | //then 301 | expect(_actionDispatcher.currentAction).toEqual({ 302 | type: "ACTION_NAME", 303 | dynamic: "something", 304 | }); 305 | expect(_actionDispatcher.currentPath).toEqual("/something/something"); 306 | 307 | //when 308 | fireUrlChange("/hi/something"); 309 | //then 310 | expect(_actionDispatcher.currentAction).toEqual({ 311 | type: "ACTION_NAME", 312 | dynamic: "foo", 313 | }); 314 | expect(_actionDispatcher.currentPath).toEqual("/hi/something"); 315 | }); 316 | 317 | it("router handles the current location when initialized", () => { 318 | // given 319 | const routesConfig = [ 320 | ["/something/:dynamic", "ACTION_NAME", {}], 321 | ["/:dyn/something", "ACTION_NAME", {}], 322 | ]; 323 | 324 | // when 325 | /// We break the pattern because we're testing store construction. 326 | const { actionsDispatched, init } = setupTest( 327 | routesConfig, 328 | "/something/something" 329 | ); 330 | init(); 331 | 332 | // then 333 | expect(actionsDispatched()).toEqual([ 334 | { type: "ACTION_NAME", dynamic: "something" }, 335 | ]); 336 | }); 337 | 338 | it("pathForAction should render a route", () => { 339 | // given 340 | const routesConfig = [ 341 | ["/something/:dynamic", "ACTION_NAME", {}], 342 | ["/:dyn/something", "ACTION_NAME", {}], 343 | ]; 344 | const action = { type: "ACTION_NAME", dynamic: "hooray" }; 345 | const { _actionDispatcher } = setupTest(routesConfig); 346 | // when 347 | const actual = _actionDispatcher.pathForAction(action); 348 | 349 | // then 350 | expect(actual).toEqual("/something/hooray"); 351 | }); 352 | 353 | it("cannot be double init'd", () => { 354 | // given 355 | const routesConfig = [ 356 | ["/something/:dynamic", "ACTION_NAME", {}], 357 | ["/:dyn/something", "ACTION_NAME", {}], 358 | ]; 359 | const { init, fireUrlChange, _actionDispatcher } = setupTest( 360 | routesConfig, 361 | "/something/foo" 362 | ); 363 | // when 364 | init(); 365 | expect(_actionDispatcher.currentPath).toEqual("/something/foo"); 366 | fireUrlChange("/foo/something"); 367 | expect(_actionDispatcher.currentPath).toEqual("/foo/something"); 368 | init(); 369 | expect(_actionDispatcher.currentPath).toEqual("/foo/something"); 370 | }); 371 | 372 | //eslint-disable-next-line no-console 373 | console.log = console_log; 374 | -------------------------------------------------------------------------------- /src/tests/change-url-event.test.js: -------------------------------------------------------------------------------- 1 | import addChangeUrlEvent from "../change-url-event"; 2 | 3 | it("it should add changeUrlEventCreator to popstate,pushstate,replacestate", () => { 4 | // given 5 | const window = {}; 6 | const map = {}; 7 | 8 | window.addEventListener = jest.fn((event, cb) => { 9 | map[event] = cb; 10 | }); 11 | 12 | // when 13 | addChangeUrlEvent(window); 14 | 15 | // then 16 | expect(map["popstate"]).toBeDefined(); 17 | expect(map["pushstate"]).toBeDefined(); 18 | expect(map["replacestate"]).toBeDefined(); 19 | }); 20 | 21 | it("given event handler should generate a urlchange event only when url changes", () => { 22 | // given 23 | const window = { 24 | location: { 25 | hash: "#hash", 26 | host: "example.com", 27 | hostname: "example", 28 | origin: "", 29 | href: "", 30 | pathname: "/path/to/thing", 31 | port: 80, 32 | protocol: "https:", 33 | }, 34 | }; 35 | const map = {}; 36 | const calls = []; 37 | 38 | window.addEventListener = jest.fn((event, cb) => { 39 | map[event] = cb; 40 | }); 41 | 42 | window.dispatchEvent = jest.fn(ev => { 43 | const evName = ev.type; 44 | calls.push(ev); 45 | if (map[evName]) { 46 | map[evName].handleEvent(ev); 47 | } 48 | }); 49 | 50 | // when 51 | addChangeUrlEvent(window); 52 | window.dispatchEvent(new Event("popstate")); 53 | window.dispatchEvent(new Event("popstate")); 54 | 55 | // then 56 | expect(calls.length).toEqual(3); 57 | expect(calls[1].type).toEqual("urlchanged"); 58 | expect(calls[1].detail).toEqual(window.location); 59 | 60 | //when 61 | window.location.pathname = "/new/path"; 62 | window.dispatchEvent(new Event("popstate")); 63 | 64 | //then 65 | expect(calls.length).toEqual(5); 66 | expect(calls[4].type).toEqual("urlchanged"); 67 | expect(calls[4].detail).toEqual(window.location); 68 | }); 69 | 70 | it("should only add url events 1x when addChangeUrlEvent is called on window more than 1x", () => { 71 | // given 72 | const window = {}; 73 | const map = {}; 74 | 75 | window.addEventListener = jest.fn((event, cb) => { 76 | if (!map[event]) { 77 | map[event] = []; 78 | } 79 | map[event].push(cb); 80 | }); 81 | 82 | // when 83 | addChangeUrlEvent(window); 84 | addChangeUrlEvent(window); 85 | addChangeUrlEvent(window); 86 | 87 | expect(Object.keys(map).length).toEqual(3); 88 | //then 89 | for (let event of Object.keys(map)) { 90 | expect(map[event].length).toEqual(1); 91 | } 92 | }); 93 | -------------------------------------------------------------------------------- /src/tests/fragment.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Fragment from "../fragment"; 3 | import ezJson from "enzyme-to-json"; 4 | import Enzyme, { shallow } from "enzyme"; 5 | import Adapter from "enzyme-adapter-react-16"; 6 | Enzyme.configure({ adapter: new Adapter() }); 7 | 8 | it("should display when state is truthy", () => { 9 | // given 10 | const state = { property: true }; 11 | // when 12 | 13 | const wrapper = shallow( 14 | 15 |
Hello
16 |
17 | ); 18 | 19 | // then 20 | expect(ezJson(wrapper)).toMatchSnapshot(); 21 | }); 22 | 23 | it("should not display when state is falsy", () => { 24 | // given 25 | const state = { property: undefined }; 26 | // when 27 | 28 | const wrapper = shallow( 29 | 30 |
Hello
31 |
32 | ); 33 | 34 | // then 35 | expect(ezJson(wrapper)).toBeFalsy(); 36 | }); 37 | 38 | it("should handle paths in the state tree", () => { 39 | // given 40 | const state = { property: { subproperty: true } }; 41 | // when 42 | 43 | const wrapper = shallow( 44 | 45 |
Hello
46 |
47 | ); 48 | 49 | // then 50 | expect(ezJson(wrapper)).toMatchSnapshot(); 51 | }); 52 | 53 | it("should handle arrays in the state tree", () => { 54 | // given 55 | const state = { property: [{ bar: {} }] }; 56 | // when 57 | 58 | const wrapper = shallow( 59 | 60 |
Hello
61 |
62 | ); 63 | 64 | // then 65 | expect(ezJson(wrapper)).toMatchSnapshot(); 66 | }); 67 | 68 | it("should be falsy if missing state tree", () => { 69 | // given 70 | const state = { property: { subproperty: true } }; 71 | 72 | const wrapper = shallow( 73 | 74 |
Hello
75 |
76 | ); 77 | 78 | expect(ezJson(wrapper)).toBeFalsy(); 79 | }); 80 | -------------------------------------------------------------------------------- /src/tests/history-events.test.js: -------------------------------------------------------------------------------- 1 | import addMissingHistoryEvents from "../history-events"; 2 | 3 | it("should overwrite pushstate and replacestate with event-emitting functions", () => { 4 | // given 5 | const pushState = jest.fn(); 6 | const replaceState = jest.fn(); 7 | const window = { 8 | dispatchEvent: jest.fn(), 9 | history: { 10 | pushState, 11 | replaceState, 12 | }, 13 | }; 14 | 15 | // when 16 | addMissingHistoryEvents(window, window.history); 17 | window.history.pushState({ item: "push" }, "pushstate", "/pushstate"); 18 | window.history.replaceState( 19 | { item: "replace" }, 20 | "replacestate", 21 | "/replacestate" 22 | ); 23 | 24 | //then 25 | expect(pushState.mock.calls).toEqual([ 26 | [{ item: "push" }, "pushstate", "/pushstate"], 27 | ]); 28 | expect(replaceState.mock.calls).toEqual([ 29 | [{ item: "replace" }, "replacestate", "/replacestate"], 30 | ]); 31 | expect(window.dispatchEvent.mock.calls.length).toEqual(2); 32 | const windowCalls = window.dispatchEvent.mock.calls; 33 | 34 | expect(windowCalls[0][0].detail).toEqual({ 35 | state: { item: "push" }, 36 | title: "pushstate", 37 | url: "/pushstate", 38 | }); 39 | expect(windowCalls[1][0].detail).toEqual({ 40 | state: { item: "replace" }, 41 | title: "replacestate", 42 | url: "/replacestate", 43 | }); 44 | }); 45 | 46 | it("should only add history-events once if called any number of times on same objects", () => { 47 | // given 48 | const pushState = jest.fn(); 49 | const replaceState = jest.fn(); 50 | const window = { 51 | dispatchEvent: jest.fn(), 52 | history: { 53 | pushState, 54 | replaceState, 55 | }, 56 | }; 57 | 58 | // when 59 | addMissingHistoryEvents(window, window.history); 60 | addMissingHistoryEvents(window, window.history); 61 | addMissingHistoryEvents(window, window.history); 62 | addMissingHistoryEvents(window, window.history); 63 | 64 | window.history.pushState({ item: "push" }, "pushstate", "/pushstate"); 65 | window.history.replaceState( 66 | { item: "replace" }, 67 | "replacestate", 68 | "/replacestate" 69 | ); 70 | 71 | //then 72 | expect(window.dispatchEvent.mock.calls.length).toEqual(2); 73 | }); 74 | -------------------------------------------------------------------------------- /src/tests/provider-api.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | import { act } from "react-dom/test-utils"; 4 | import {createRouteDispatcher} from "../route-dispatcher"; 5 | import { 6 | RouteLink, 7 | RouteProvider, 8 | withRoute, 9 | } from "../provider-api"; 10 | import { createFakeWindow, createLocation } from "./test-utils"; 11 | import Enzyme, { mount } from "enzyme"; 12 | 13 | const routesConfig = [ 14 | ["/foo", "foo", {}], 15 | ["/bar/:id", "bar", {}], 16 | ]; 17 | 18 | Enzyme.configure({ adapter: new Adapter() }); 19 | 20 | describe("RouteProvider and helpers", () => { 21 | test("RouteProvider gives RouteLink correct info", () => { 22 | // given 23 | const _window = createFakeWindow("/foo"); 24 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 25 | const wrapper = mount( 26 | 27 | 28 | 29 | ); 30 | // when 31 | const href = wrapper.find("RouteLink").find("a").prop("href"); 32 | // then 33 | expect(href).toEqual("/bar/foo"); 34 | }); 35 | 36 | test("RouteLink calls dispatcher.receiveRoute on click", () => { 37 | // given 38 | const _window = createFakeWindow("/foo"); 39 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 40 | let received = null; 41 | routeDispatcher.receiveRoute = route => { 42 | received = route; 43 | }; 44 | const wrapper = mount( 45 | 46 | 47 | 48 | ); 49 | // when 50 | wrapper.find("a").simulate("click"); 51 | // then 52 | expect(received).toEqual({ routeName: "bar", id: "foo" }); 53 | }); 54 | 55 | test("withRoute", () => { 56 | // given 57 | const DummyWithRoute = withRoute(({ route, children }) => { 58 | return ( 59 |
60 | {JSON.stringify(route)} 61 | {children} 62 |
63 | ); 64 | }); 65 | const _window = createFakeWindow("/foo"); 66 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 67 | 68 | let wrapper; 69 | act(() => { 70 | wrapper = mount( 71 | 72 | children text 73 | 74 | ); 75 | }); 76 | // when 77 | 78 | let routeText = wrapper.find(".route").text(); 79 | const childrenText = wrapper.find(".children").text(); 80 | // then 81 | expect(routeText).toEqual(JSON.stringify({ routeName: "foo" })); 82 | expect(childrenText).toEqual("children text"); 83 | 84 | act(() => { 85 | routeDispatcher.receiveLocation(createLocation("/bar/foo")); 86 | }); 87 | routeText = wrapper.find(".route").text(); 88 | // then 89 | expect(routeText).toEqual(JSON.stringify({ id: "foo", routeName: "bar" })); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/tests/route-dispatcher.test.js: -------------------------------------------------------------------------------- 1 | import {createFakeWindow, createLocation} from "./test-utils"; 2 | import {createRouteDispatcher} from "../route-dispatcher"; 3 | 4 | const routesConfig = [ 5 | ["/foo", "foo", {}], 6 | ["/bar/:id", "bar", {}], 7 | ]; 8 | 9 | describe("routeDispatcher", () => { 10 | test("receiveRoute and addRouteListener work together", () => { 11 | //given 12 | const _window = createFakeWindow("/foo"); 13 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 14 | let thing = null; 15 | routeDispatcher.addRouteListener(r => { 16 | thing = r; 17 | }); 18 | //when 19 | routeDispatcher.receiveRoute({ routeName: "foo" }); 20 | //then 21 | expect(thing).toEqual({ routeName: "foo" }); 22 | }); 23 | 24 | test("when receiveLocation is called, routeListener gets route not action", () => { 25 | //given 26 | const _window = createFakeWindow("/foo"); 27 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 28 | let thing = null; 29 | routeDispatcher.addRouteListener(r => { 30 | thing = r; 31 | }); 32 | //when 33 | routeDispatcher.receiveLocation(createLocation("/bar/hi")); 34 | //then 35 | expect(thing).toEqual({ routeName: "bar", id: "hi" }); 36 | }); 37 | 38 | test("currentRoute returns the correct route object", () => { 39 | //given 40 | const _window = createFakeWindow("/foo"); 41 | const routeDispatcher = createRouteDispatcher(routesConfig, _window); 42 | //when 43 | routeDispatcher.receiveLocation(createLocation("/bar/hi")); 44 | //then 45 | expect(routeDispatcher.currentRoute).toEqual({ 46 | routeName: "bar", 47 | id: "hi", 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/tests/test-utils.js: -------------------------------------------------------------------------------- 1 | export function createLocation(path) { 2 | return { 3 | hash: "#hash", 4 | host: "example.com", 5 | hostname: "example", 6 | origin: "", 7 | href: "", 8 | pathname: path, 9 | port: 80, 10 | protocol: "https:", 11 | }; 12 | } 13 | 14 | export function createFakeWindow(path = "/path/to/thing") { 15 | let locations = [createLocation("(root)")]; 16 | function pushLocation(window, path) { 17 | let newLoc = createLocation(path); 18 | locations.push(newLoc); 19 | window.location = newLoc; 20 | return newLoc; 21 | } 22 | function popLocation(window) { 23 | locations.pop(); 24 | let newLoc = locations[locations.length - 1]; 25 | window.location = newLoc; 26 | return newLoc; 27 | } 28 | 29 | const window = { 30 | history: { 31 | pushState: jest.fn((_, __, path) => { 32 | window.location = pushLocation(window, path); 33 | }), 34 | replaceState: jest.fn(), 35 | }, 36 | }; 37 | 38 | pushLocation(window, path); 39 | const map = {}; 40 | 41 | window.addEventListener = jest.fn((event, cb) => { 42 | map[event] = cb; 43 | }); 44 | 45 | function prepareEvent(window, evName) { 46 | if (evName === "popstate") { 47 | window.location = popLocation(window); 48 | } 49 | } 50 | 51 | window.dispatchEvent = jest.fn(ev => { 52 | const evName = ev.type; 53 | if (map[evName]) { 54 | prepareEvent(window, evName); 55 | map[evName].handleEvent(ev); 56 | } 57 | }); 58 | 59 | return window; 60 | } 61 | --------------------------------------------------------------------------------