├── .browserslistrc ├── .DS_Store ├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmrc ├── .travis.yml ├── LICENSE ├── README.md ├── example └── src │ ├── components │ ├── AppLink │ │ └── index.tsx │ ├── Logger │ │ └── index.tsx │ ├── LoggerHOC │ │ └── index.tsx │ ├── LoggerHooks │ │ └── index.tsx │ ├── ManualHistory │ │ └── index.tsx │ └── PreviewLocation │ │ └── index.tsx │ ├── index.ejs │ ├── index.tsx │ ├── pages │ ├── About.tsx │ ├── Contact.tsx │ ├── FixedRedirect.tsx │ ├── FixedRedirectManual.tsx │ ├── Home.tsx │ ├── Page.tsx │ └── RegularRedirect.tsx │ └── styles.js ├── jest.config.json ├── package.json ├── src ├── LastLocationContext.ts ├── LastLocationProvider.tsx ├── RedirectWithoutLastLocation.spec.tsx ├── RedirectWithoutLastLocation.tsx ├── index.ts ├── prevent.ts ├── types.ts ├── useLastLocation.spec.tsx ├── useLastLocation.ts ├── withLastLocation.spec.tsx └── withLastLocation.tsx ├── tests └── setup.js ├── tsconfig.json ├── webpack.config.js └── yarn.lock / .browserslistrc: -------------------------------------------------------------------------------- 1 | { 2 | "browserslist": [ 3 | "defaults" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hinok/react-router-last-location/2d57c676abb49f3f5755c44fbe2c24b01a8bb68b/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "@babel/preset-typescript", 10 | "@babel/preset-react", 11 | "linaria/babel" 12 | ], 13 | "env": { 14 | "test": { 15 | "plugins": [ 16 | "@babel/plugin-transform-modules-commonjs" 17 | ] 18 | } 19 | }, 20 | "plugins": [ 21 | "@babel/plugin-syntax-dynamic-import", 22 | "@babel/plugin-syntax-import-meta", 23 | "@babel/plugin-proposal-class-properties", 24 | "@babel/plugin-proposal-json-strings", 25 | [ 26 | "@babel/plugin-proposal-decorators", 27 | { 28 | "legacy": true 29 | } 30 | ], 31 | "@babel/plugin-proposal-function-sent", 32 | "@babel/plugin-proposal-export-namespace-from", 33 | "@babel/plugin-proposal-numeric-separator", 34 | "@babel/plugin-proposal-throw-expressions" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs. 2 | # More information at http://editorconfig.org 3 | 4 | # No .editorconfig files above the root directory 5 | root = true 6 | 7 | [*] 8 | indent_style = space 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | max_line_length = 100 14 | 15 | [*.{html,hbs,mustache,ejs,js,jsx,ts,tsx,rb,scss,xml,svg,json}] 16 | indent_size = 2 17 | 18 | [*.md] 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/* 2 | /dist/* 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parserOptions": { 4 | "ecmaVersion": 7, 5 | "sourceType": "module", 6 | "ecmaFeatures": { 7 | "impliedStrict": true, 8 | "jsx": true 9 | } 10 | }, 11 | "parser": "babel-eslint", 12 | "env": { 13 | "browser": true, 14 | "node": true, 15 | "commonjs": true, 16 | "es6": true, 17 | "jest": true 18 | }, 19 | "rules": { 20 | "react/jsx-filename-extension": "off", 21 | "react/jsx-one-expression-per-line": "off", 22 | "jsx-a11y/anchor-is-valid": [ 23 | "error", 24 | { 25 | "components": ["Link"], 26 | "specialLink": ["to"] 27 | } 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project 2 | /dist 3 | /example/dist 4 | /coverage 5 | /.linaria-cache 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (http://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Typescript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | scripts-prepend-node-path=true 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 16 4 | install: 5 | - npm install -g yarn 6 | - yarn install 7 | script: 8 | - yarn run ci 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dawid Karabin 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 | [![Build Status](https://travis-ci.org/hinok/react-router-last-location.svg?branch=master)](https://travis-ci.org/hinok/react-router-last-location) 2 | [![Coverage Status](https://coveralls.io/repos/github/hinok/react-router-last-location/badge.svg?branch=master)](https://coveralls.io/github/hinok/react-router-last-location?branch=master) 3 | 4 | # react-router-last-location 5 | 6 | - Provides access to the last location in `react` + `react-router (v4.x, v5.x)` applications. 7 | - ❤️ Using [`hooks`](https://reactjs.org/docs/hooks-overview.html)? If yes, `useLastLocation`. 8 | - 💉 Using [`HOC`](https://reactjs.org/docs/higher-order-components.html)? - If yes, `withLastLocation`. 9 | - Handle redirects. 10 | - Support ![TypeScript](https://user-images.githubusercontent.com/1313605/53197634-df9a6d00-361a-11e9-81ba-69f8a941f8a2.png) 11 | - Useful for handling internal routing. 12 | - Easily keep your users inside your app. 13 | 14 | ## Demo 15 | 16 | [![Edit react-router-last-location](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/zn208l91zp) 17 | 18 | ## Note: Last location != Previous browser history state 19 | 20 | This library only returns the location that was active right before the recent location change, during the lifetime of the current window. 21 | 22 | This means, it is not equal to the "location you were at before navigating to this history state". 23 | 24 | In other words, the location this library provides is not necessarily the same as the one when you click the browser's back button. 25 | 26 | **Example 1** 27 | 28 | 1. Visit `/`: last location = `null`, previous browser history state = `null` 29 | 2. Visit `/a`: last location = `/`, previous browser history state = `/` 30 | 3. Visit `/b`: last location = `/a`, previous browser history state = `/a` 31 | 4. Reload (url will stay at `/b`): last location = `null`, previous browser history state = `/a` 32 | 33 | **Example 2** 34 | 35 | 1. Visit `/`: last location = `null` 36 | 2. Visit `/a`: last location = `/` 37 | 3. Visit `/b`: last location = `/a` 38 | 4. Go back: last location = `/b`, previous browser history state = `/` 39 | 40 | **Example 3** 41 | 42 | 1. Visit `/`: last location = `null` 43 | 2. Visit `/a`: last location = `/` 44 | 3. Visit `/b`: last location = `/a` 45 | 4. Visit `/c`: last location = `/b` 46 | 4. Go back to `/a` (by selecting that state explicitly in "Go back" browser dropdown that is visible upon clicking it with right mouse button): last location = `/c`, previous browser history state = `/` 47 | 48 | ## How to use? 49 | 50 | ```bash 51 | # Please remember that you should have installed react, prop-types and react-router-dom packages 52 | # npm install react prop-types react-router-dom --save 53 | 54 | npm install react-router-last-location --save 55 | ``` 56 | 57 | **If you still use `v1.x.x` and would like to use hook `useLastLocation`, please upgrade to `v2.x.x` version (don't worry, no breaking changes).** 58 | 59 | ```bash 60 | npm install react-router-last-location@^2.0.0 61 | # or 62 | npm install react-router-last-location@latest 63 | ``` 64 | 65 | ### Declare `` inside ``. 66 | 67 | `index.js` 68 | 69 | ```jsx 70 | import React from 'react'; 71 | import { render } from 'react-dom'; 72 | import { BrowserRouter as Router, Route, Link } from 'react-router-dom'; 73 | import { LastLocationProvider } from 'react-router-last-location'; 74 | import Home from './pages/Home'; 75 | import About from './pages/About'; 76 | import Contact from './pages/Contact'; 77 | import Logger from './components/Logger'; 78 | 79 | const App = () => ( 80 | 81 | 82 |
83 |
    84 |
  • Home
  • 85 |
  • About
  • 86 |
  • Contact
  • 87 |
88 | 89 |
90 | 91 | 92 | 93 | 94 | 95 |
96 | 97 | 98 |
99 |
100 |
101 | ); 102 | 103 | render(, document.getElementById('root')); 104 | ``` 105 | 106 | ### Use hook `useLastLocation` to get `lastLocation`. 107 | 108 | `./components/Logger`, [see example](https://github.com/hinok/react-router-last-location/blob/eb552e0a82df6000ba140d8f20627b8bc68716b6/example/src/components/LoggerHooks/index.js) 109 | 110 | ```jsx 111 | import React from 'react'; 112 | import { useLastLocation } from 'react-router-last-location'; 113 | 114 | const Logger = () => { 115 | const lastLocation = useLastLocation(); 116 | 117 | return ( 118 |
119 |

Logger!

120 |
121 |         {JSON.stringify(lastLocation)}
122 |       
123 |
124 | ); 125 | }; 126 | 127 | export default LoggerHooks; 128 | 129 | ``` 130 | 131 | ### Use HOC `withLastLocation` to get `lastLocation` prop. 132 | 133 | `./components/Logger`, [see example](https://github.com/hinok/react-router-last-location/blob/eb552e0a82df6000ba140d8f20627b8bc68716b6/example/src/components/LoggerHOC/index.js) 134 | 135 | ```jsx 136 | import React from 'react'; 137 | import { withLastLocation } from 'react-router-last-location'; 138 | 139 | const Logger = ({ lastLocation }) => ( 140 |
141 |

Logger!

142 |
143 |       {JSON.stringify(lastLocation)}
144 |     
145 |
146 | ); 147 | 148 | export default withLastLocation(Logger); 149 | ``` 150 | 151 | ### Use `RedirectWithoutLastLocation` to not store redirects as last location 152 | 153 | ```jsx 154 | import React from 'react'; 155 | import { RedirectWithoutLastLocation } from 'react-router-last-location'; 156 | 157 | const MyPage = () => ( 158 | 159 | ); 160 | 161 | export default MyPage; 162 | ``` 163 | 164 | You can still use a regular `` component from `react-router`. 165 | 166 | If you do, you'll then you need to manually pass the `state: { preventLastLocation: true }`, like below: 167 | 168 | ```jsx 169 | import React from 'react'; 170 | import { Redirect } from 'react-router-dom'; 171 | 172 | const MyPage = () => ( 173 | 179 | ); 180 | 181 | export default MyPage; 182 | ``` 183 | 184 | ## LastLocationProvider 185 | 186 | ### Props 187 | 188 | **`watchOnlyPathname`**, type: `boolean`, default: `false` 189 | 190 | Stores the last route only when `pathname` has changed. 191 | -------------------------------------------------------------------------------- /example/src/components/AppLink/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { NavLink, NavLinkProps } from 'react-router-dom'; 3 | 4 | /** 5 | * @see https://github.com/ReactTraining/react-router/issues/6268 6 | */ 7 | const AppLink: React.FC = (props) => { 8 | const isActive:NavLinkProps['isActive'] = (_, { pathname, search }) => `${pathname}${search}` === props.to; 9 | 10 | return ( 11 | 12 | ); 13 | }; 14 | 15 | export default AppLink; 16 | -------------------------------------------------------------------------------- /example/src/components/Logger/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | // eslint-disable-next-line import/no-extraneous-dependencies 4 | import { css } from 'linaria'; 5 | 6 | const logger = css` 7 | position: absolute; 8 | bottom: 10px; 9 | left: 10px; 10 | background: rgba(0, 0, 0, 0.7); 11 | border-radius: 3px; 12 | color: #fff; 13 | font-size: 12px; 14 | padding: 10px; 15 | width: 300px; 16 | 17 | & + & { 18 | left: 320px; 19 | } 20 | 21 | h2 { 22 | font-size: 10px; 23 | text-transform: uppercase; 24 | letter-spacing: 0.15em; 25 | margin: 0 0 1.5em; 26 | } 27 | 28 | h3 { 29 | font-family: 'Inconsolata', serif; 30 | } 31 | `; 32 | 33 | type Props = { 34 | title: React.ReactNode; 35 | data: object | null; 36 | } 37 | 38 | const Logger: React.FC = ({ title, data }) => ( 39 |
40 |

Logger

41 |

{title}

42 |
43 |       {JSON.stringify(data, undefined, 2)}
44 |     
45 |
46 | ); 47 | 48 | Logger.propTypes = { 49 | // eslint-disable-next-line react/forbid-prop-types 50 | data: PropTypes.object, 51 | title: PropTypes.node.isRequired, 52 | }; 53 | 54 | Logger.defaultProps = { 55 | data: null, 56 | }; 57 | 58 | export default Logger; 59 | -------------------------------------------------------------------------------- /example/src/components/LoggerHOC/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withLastLocation, WithLastLocationProps } from '../../../../src'; 3 | import Logger from '../Logger'; 4 | 5 | const LoggerHOC: React.FC = ({ lastLocation }) => ( 6 | 7 | ); 8 | 9 | export default withLastLocation(LoggerHOC); 10 | -------------------------------------------------------------------------------- /example/src/components/LoggerHooks/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useLastLocation } from '../../../../src'; 3 | import Logger from '../Logger'; 4 | 5 | const LoggerHooks: React.FC = () => { 6 | const lastLocation = useLastLocation(); 7 | return ; 8 | }; 9 | 10 | export default LoggerHooks; 11 | -------------------------------------------------------------------------------- /example/src/components/ManualHistory/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 3 | 4 | class ManualHistory extends React.Component { 5 | handleClick(pathname: string) { 6 | const to = { 7 | pathname, 8 | state: { 9 | from: pathname, 10 | datetime: Date.now(), 11 | }, 12 | }; 13 | 14 | const { history } = this.props; 15 | history.push(to); 16 | } 17 | 18 | render() { 19 | return ( 20 |
    21 |
  • 22 | 25 |
  • 26 |
  • 27 | 30 |
  • 31 |
  • 32 | 35 |
  • 36 |
37 | ); 38 | } 39 | } 40 | 41 | export default withRouter(ManualHistory); 42 | -------------------------------------------------------------------------------- /example/src/components/PreviewLocation/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | 4 | type Props = { 5 | location: RouteComponentProps['location'] 6 | } 7 | 8 | const PreviewLocation: React.FC = ({ location }) => ( 9 |
10 | Your current location 11 |
{JSON.stringify(location, undefined, 2)}
12 |
13 | ); 14 | 15 | export default PreviewLocation; 16 | -------------------------------------------------------------------------------- /example/src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlWebpackPlugin.options.title %> 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /example/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { BrowserRouter, Route } from 'react-router-dom'; 4 | import { LastLocationProvider } from '../../src'; 5 | import Home from './pages/Home'; 6 | import About from './pages/About'; 7 | import Contact from './pages/Contact'; 8 | import RegularRedirect from './pages/RegularRedirect'; 9 | import FixedRedirect from './pages/FixedRedirect'; 10 | import FixedRedirectManual from './pages/FixedRedirectManual'; 11 | import AppLink from './components/AppLink'; 12 | import LoggerHOC from './components/LoggerHOC'; 13 | import LoggerHooks from './components/LoggerHooks'; 14 | import ManualHistory from './components/ManualHistory'; 15 | import * as styles from './styles'; 16 | 17 | const App = () => ( 18 | 19 | 20 |
21 |
    22 |
  • Home
  • 23 |
  • About
  • 24 |
  • Contact
  • 25 |
  • Contact with search params
  • 26 |
  • {''}
  • 27 |
  • {''}
  • 28 |
  • {''}
  • 29 |
30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 | 39 |
40 | LastLocationProvider has set {'{ watchOnlyPathname: true }'} 41 | 42 | 43 |
44 |
45 |
46 | ); 47 | 48 | ReactDOM.render(, document.getElementById('root')); 49 | -------------------------------------------------------------------------------- /example/src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import Page from './Page'; 4 | 5 | const About: React.FC = ({ location }) => ( 6 | 7 | ); 8 | 9 | export default About; 10 | -------------------------------------------------------------------------------- /example/src/pages/Contact.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import Page from './Page'; 4 | 5 | export default class Contact extends React.PureComponent { 6 | render() { 7 | const { location } = this.props; 8 | return ; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /example/src/pages/FixedRedirect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import RedirectWithoutLastLocation from '../../../src/RedirectWithoutLastLocation'; 3 | 4 | const FixedRedirect: React.FC = () => ( 5 | 6 | ); 7 | 8 | export default FixedRedirect; 9 | -------------------------------------------------------------------------------- /example/src/pages/FixedRedirectManual.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | 4 | const FixedRedirectManual: React.FC = () => ( 5 | 6 | ); 7 | 8 | export default FixedRedirectManual; 9 | -------------------------------------------------------------------------------- /example/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import Page from './Page'; 4 | 5 | const Home: React.FC = ({ location }) => ( 6 | 7 | ); 8 | 9 | export default Home; 10 | -------------------------------------------------------------------------------- /example/src/pages/Page.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import PreviewLocation from '../components/PreviewLocation'; 4 | 5 | type Props = { 6 | location: RouteComponentProps['location'] 7 | title: string 8 | } 9 | 10 | const Page: React.FC = ({ title, location }) => ( 11 | <> 12 |

{title}

13 | 14 | 15 | ); 16 | 17 | export default Page; 18 | -------------------------------------------------------------------------------- /example/src/pages/RegularRedirect.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | 4 | const RegularRedirect: React.FC = () => ; 5 | 6 | export default RegularRedirect; 7 | -------------------------------------------------------------------------------- /example/src/styles.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import { css } from 'linaria'; 3 | 4 | const fonts = { 5 | sans: '\'Roboto\', sans-serif', 6 | mono: '"SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace', 7 | }; 8 | 9 | export const typeMono = css` 10 | font-family: ${fonts.mono}; 11 | `; 12 | 13 | export const globals = css` 14 | :global { 15 | html { 16 | box-sizing: border-box; 17 | height: 100%; 18 | width: 100%; 19 | font-size: 100%; 20 | } 21 | 22 | body { 23 | margin: 0; 24 | padding: 0; 25 | height: 100%; 26 | width: 100%; 27 | font-family: ${fonts.sans}; 28 | line-height: 1.5; 29 | } 30 | 31 | *, 32 | *:before, 33 | *:after { 34 | box-sizing: inherit; 35 | } 36 | 37 | hr { 38 | border: 0; 39 | height: 1px; 40 | background: rgba(0, 0, 0, 0.12); 41 | } 42 | 43 | pre { 44 | font-family: ${fonts.mono}; 45 | margin: 0; 46 | padding: 0; 47 | font-size: 0.75rem; 48 | } 49 | } 50 | `; 51 | 52 | export const app = css` 53 | padding: 20px; 54 | `; 55 | 56 | export const header = css` 57 | margin: 0; 58 | padding: 0; 59 | display: flex; 60 | list-style: none; 61 | 62 | li:not(:last-child) { 63 | margin-right: 15px; 64 | } 65 | 66 | a { 67 | position: relative; 68 | text-decoration: none; 69 | color: royalblue; 70 | } 71 | 72 | a:global(.active) { 73 | font-weight: bold; 74 | 75 | &::before { 76 | content: ''; 77 | display: block; 78 | position: absolute; 79 | bottom: 0; 80 | margin-bottom: -13px; 81 | left: 0; 82 | width: 100%; 83 | height: 2px; 84 | background: royalblue; 85 | } 86 | } 87 | 88 | .${typeMono} { 89 | font-size: 0.875em; 90 | } 91 | `; 92 | 93 | export const badge = css` 94 | font-family: ${fonts.mono}; 95 | font-size: 0.75em; 96 | line-height: 1; 97 | border-radius: 3px; 98 | background: rgba(0, 0, 0, 0.1); 99 | padding: 3px 5px; 100 | `; 101 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "collectCoverageFrom": [ 3 | "src/**/*.js", 4 | "src/**/*.ts", 5 | "src/**/*.tsx" 6 | ], 7 | "coveragePathIgnorePatterns": [ 8 | "/node_modules/", 9 | "/src/example", 10 | "/src/index" 11 | ], 12 | "coverageThreshold": { 13 | "global": { 14 | "branches": 70, 15 | "functions": 70, 16 | "lines": 70, 17 | "statements": 70 18 | } 19 | }, 20 | "transform": { 21 | "^.+\\.tsx?$": "ts-jest", 22 | "^.+\\.js$": "/node_modules/babel-jest" 23 | }, 24 | "setupFilesAfterEnv": [ 25 | "/tests/setup.js" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-last-location", 3 | "version": "2.0.1", 4 | "description": "Provides access to the last location in react + react-router (v4.x) apps. Useful for handling internal routing. Easily prevent leaving your app by users.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "sideEffects": false, 8 | "repository": "git@github.com:hinok/react-router-last-location.git", 9 | "author": "Dawid Karabin ", 10 | "license": "MIT", 11 | "files": [ 12 | "/dist", 13 | "/src" 14 | ], 15 | "keywords": [ 16 | "react", 17 | "router", 18 | "route", 19 | "routing", 20 | "history", 21 | "location", 22 | "entries", 23 | "link" 24 | ], 25 | "scripts": { 26 | "start": "webpack-dev-server --open --env.example", 27 | "serve": "serve ./example/dist", 28 | "clean": "rm -rf ./dist && rm -rf ./example/dist", 29 | "prebuild": "npm run clean", 30 | "build": "webpack --config webpack.config.js --env.prod", 31 | "prepublish": "npm run build", 32 | "test": "jest --config=jest.config.json", 33 | "test:watch": "jest --config=jest.config.json --watch", 34 | "test:coverage": "jest --config=jest.config.json --coverage", 35 | "lint": "eslint . --ext .js", 36 | "ci": "run-p lint build test:coverage", 37 | "postci": "cat ./coverage/lcov.info | coveralls" 38 | }, 39 | "peerDependencies": { 40 | "prop-types": "^15.6.0", 41 | "react": "^15.5.4 || ^16.0.0 || ^17.0.0", 42 | "react-dom": "^15.5.4 || ^16.0.0 || ^17.0.0", 43 | "react-router-dom": "^4.1.1 || ^5.0.1" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.5.0", 47 | "@babel/plugin-proposal-class-properties": "^7.5.0", 48 | "@babel/plugin-proposal-decorators": "^7.4.4", 49 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0", 50 | "@babel/plugin-proposal-function-sent": "^7.5.0", 51 | "@babel/plugin-proposal-json-strings": "^7.0.0", 52 | "@babel/plugin-proposal-numeric-separator": "^7.0.0", 53 | "@babel/plugin-proposal-throw-expressions": "^7.0.0", 54 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 55 | "@babel/plugin-syntax-import-meta": "^7.0.0", 56 | "@babel/plugin-transform-modules-commonjs": "^7.5.0", 57 | "@babel/preset-env": "^7.5.0", 58 | "@babel/preset-react": "^7.0.0", 59 | "@babel/preset-typescript": "^7.3.3", 60 | "@testing-library/dom": "^8.0.0", 61 | "@testing-library/jest-dom": "^5.14.1", 62 | "@testing-library/react": "^12.0.0", 63 | "@testing-library/react-hooks": "^7.0.0", 64 | "@testing-library/user-event": "^13.1.9", 65 | "@types/jest": "^24.0.15", 66 | "@types/prop-types": "^15.7.1", 67 | "@types/react": "^16.8.23", 68 | "@types/react-dom": "^16.8.4", 69 | "@types/react-router-dom": "^4.3.4", 70 | "babel-core": "^7.0.0-bridge.0", 71 | "babel-eslint": "^10.0.2", 72 | "babel-jest": "^24.8.0", 73 | "babel-loader": "^8.0.6", 74 | "coveralls": "^3.0.4", 75 | "css-hot-loader": "^1.4.4", 76 | "css-loader": "^3.0.0", 77 | "eslint": "^5.3.0", 78 | "eslint-config-airbnb": "^17.1.0", 79 | "eslint-plugin-import": "^2.14.0", 80 | "eslint-plugin-jsx-a11y": "^6.1.1", 81 | "eslint-plugin-react": "^7.11.0", 82 | "html-webpack-plugin": "^3.2.0", 83 | "jest": "^24.8.0", 84 | "linaria": "^1.4.0-alpha.1", 85 | "mini-css-extract-plugin": "^0.7.0", 86 | "npm-run-all": "^4.1.5", 87 | "react": "^16.8.6", 88 | "react-dom": "^16.8.6", 89 | "react-router-dom": "^5.0.1", 90 | "react-test-renderer": "16.8.6", 91 | "serve": "^11.0.2", 92 | "ts-jest": "^24.0.2", 93 | "ts-loader": "^6.0.4", 94 | "typescript": "^3.5.2", 95 | "webpack": "^4.35.2", 96 | "webpack-cli": "^3.3.5", 97 | "webpack-dev-server": "^3.7.2", 98 | "webpack-merge": "^4.2.1" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/LastLocationContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | 4 | export type LastLocationType = null | RouteComponentProps['location']; 5 | 6 | const LastLocationContext = createContext(null); 7 | 8 | export default LastLocationContext; 9 | -------------------------------------------------------------------------------- /src/LastLocationProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import { RouteComponentProps, withRouter } from 'react-router-dom'; 4 | import LastLocationContext, { LastLocationType } from './LastLocationContext'; 5 | import { Assign } from './types'; 6 | import { hasBeenPrevented, prevent, shouldPrevent } from './prevent'; 7 | 8 | let lastLocation: LastLocationType = null; 9 | 10 | type UpdateLastLocation = { 11 | location: LastLocationType; 12 | nextLocation: RouteComponentProps['location']; 13 | watchOnlyPathname: boolean; 14 | }; 15 | 16 | const updateLastLocation = ({ location, nextLocation, watchOnlyPathname }: UpdateLastLocation) => { 17 | if (location === null) { 18 | return; 19 | } 20 | 21 | if (nextLocation === location) { 22 | return; 23 | } 24 | 25 | if (watchOnlyPathname && location.pathname === nextLocation.pathname) { 26 | return; 27 | } 28 | 29 | if (shouldPrevent(nextLocation) && !hasBeenPrevented(nextLocation)) { 30 | prevent(nextLocation); 31 | return; 32 | } 33 | 34 | lastLocation = { ...location }; 35 | }; 36 | 37 | interface Props extends RouteComponentProps { 38 | watchOnlyPathname: boolean; 39 | children: React.ReactNode; 40 | } 41 | 42 | type State = Readonly<{ 43 | currentLocation: LastLocationType; 44 | }>; 45 | 46 | class LastLocationProvider extends React.Component { 47 | static propTypes = { 48 | watchOnlyPathname: PropTypes.bool, 49 | children: PropTypes.node.isRequired, 50 | }; 51 | 52 | static defaultProps: Pick = { 53 | watchOnlyPathname: false, 54 | }; 55 | 56 | static getDerivedStateFromProps(props: Props, state: State) { 57 | updateLastLocation({ 58 | location: state.currentLocation, 59 | nextLocation: props.location, 60 | watchOnlyPathname: props.watchOnlyPathname, 61 | }); 62 | 63 | return { 64 | currentLocation: props.location, 65 | }; 66 | } 67 | 68 | readonly state: State = { 69 | currentLocation: null, 70 | }; 71 | 72 | render() { 73 | const { children } = this.props; 74 | 75 | return ( 76 | {children} 77 | ); 78 | } 79 | } 80 | 81 | export const getLastLocation = () => lastLocation; 82 | 83 | export const setLastLocation = (nextLastLocation: LastLocationType) => { 84 | lastLocation = nextLastLocation; 85 | }; 86 | 87 | /** 88 | * Unfortunately defaultProps doesn't work in this case 89 | * @see https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#support-for-defaultprops-in-jsx 90 | * 91 | * Related issues: 92 | * @see https://github.com/Microsoft/TypeScript/issues/23812#issuecomment-426771485 93 | * @see https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28515 94 | */ 95 | type ExportedProps = Assign< 96 | Pick>, 97 | { watchOnlyPathname?: boolean } 98 | >; 99 | 100 | export default withRouter(LastLocationProvider as React.ComponentClass); 101 | -------------------------------------------------------------------------------- /src/RedirectWithoutLastLocation.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | MemoryRouter, Switch, Route, Link, RedirectProps, 4 | } from 'react-router-dom'; 5 | import { LastLocationType } from './LastLocationContext'; 6 | import useLastLocation from './useLastLocation'; 7 | import RedirectWithoutLastLocation from './RedirectWithoutLastLocation'; 8 | import LastLocationProvider, { setLastLocation } from './LastLocationProvider'; 9 | import { screen, render } from '@testing-library/react'; 10 | import userEvent from '@testing-library/user-event' 11 | 12 | 13 | function rtlRender({ redirectTo }: { redirectTo: RedirectProps['to'] }) { 14 | const Page: React.FC = ({ children }) => { 15 | const lastLocation: LastLocationType = useLastLocation(); 16 | 17 | return ( 18 | <> 19 |

{children}

20 | 21 | 22 | ); 23 | }; 24 | 25 | const Home = () => Home; 26 | const About = () => About; 27 | const Secret: React.FC = () => ; 28 | 29 | return render( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | HomeLink 39 | AboutLink 40 | SecretLink 41 | 42 | ) 43 | 44 | } 45 | afterEach(() => setLastLocation(null)) 46 | describe('RedirectWithoutLastLocation', () => { 47 | const runTest = () => { 48 | // Check if lastLocation is empty 49 | screen.getByTestId(/last-location-pathname/i) 50 | // Check if we are on the homepage 51 | screen.getByRole('heading', { 52 | name: /home/i 53 | }) 54 | 55 | // Visit /about 56 | userEvent.click(screen.getByText(/AboutLink/i)) 57 | 58 | // Check if we are on the /about 59 | screen.getAllByRole('heading', { 60 | name: /about/i 61 | }) 62 | 63 | // Check lastLocation 64 | expect(screen.getByTestId(/last-location-pathname/i)).toHaveTextContent('/') 65 | 66 | // Visit /secret 67 | userEvent.click(screen.getByText(/SecretLink/i)) 68 | // We Should be redirected back to homepage 69 | screen.getByRole('heading', { 70 | name: /home/i 71 | }) 72 | 73 | // The lastLocation should point to /about 74 | expect(screen.getByTestId(/last-location-pathname/i)).toHaveTextContent('/about') 75 | }; 76 | 77 | describe('When `to` is passed as a string', () => { 78 | it('should NOT store redirected route', () => { 79 | rtlRender({ redirectTo: '/' }) 80 | runTest(); 81 | 82 | }); 83 | }); 84 | describe('When `to` is passed as an object', () => { 85 | it('should store redirected route', () => { 86 | rtlRender({ 87 | redirectTo: { 88 | pathname: '/', 89 | }, 90 | }) 91 | runTest(); 92 | }); 93 | }) 94 | 95 | }); 96 | -------------------------------------------------------------------------------- /src/RedirectWithoutLastLocation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Redirect, RedirectProps } from 'react-router-dom'; 3 | import { createLocation } from 'history'; 4 | 5 | const RedirectWithoutLastLocation: React.FC = ({ to, ...rest }) => { 6 | let finalTo; 7 | 8 | if (typeof to === 'string') { 9 | finalTo = createLocation(to, { preventLastLocation: true }); 10 | } else { 11 | finalTo = { 12 | ...to, 13 | state: { 14 | preventLastLocation: true, 15 | ...to.state, 16 | }, 17 | }; 18 | } 19 | 20 | return ; 21 | }; 22 | 23 | export default RedirectWithoutLastLocation; 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import withLastLocation, { WithLastLocationProps } from './withLastLocation'; 2 | import LastLocationProvider from './LastLocationProvider'; 3 | import useLastLocation from './useLastLocation'; 4 | import { LastLocationType } from './LastLocationContext'; 5 | import RedirectWithoutLastLocation from './RedirectWithoutLastLocation'; 6 | 7 | export { 8 | WithLastLocationProps, 9 | LastLocationType, 10 | withLastLocation, 11 | LastLocationProvider, 12 | useLastLocation, 13 | RedirectWithoutLastLocation, 14 | }; 15 | -------------------------------------------------------------------------------- /src/prevent.ts: -------------------------------------------------------------------------------- 1 | import { RouteComponentProps } from 'react-router-dom'; 2 | 3 | type RRLocation = RouteComponentProps['location'] & { [key: string]: any }; 4 | type LiteLocation = Pick>; 5 | 6 | /** 7 | * I could only check `key` here but according to: 8 | * @see https://reacttraining.com/react-router/web/api/location 9 | * `key` property is not available when HashHistory is used. 10 | */ 11 | const props = ['key', 'pathname', 'search', 'hash']; 12 | const isEqual = (a: LiteLocation, b: LiteLocation) => props.every(prop => a[prop] === b[prop]); 13 | 14 | const prevented: LiteLocation[] = []; 15 | 16 | export const prevent = (location: RRLocation) => { 17 | const { state, ...rest } = location; 18 | prevented.push(rest); 19 | }; 20 | 21 | export const hasBeenPrevented = (location: RRLocation) => 22 | prevented.some(preventedLocation => isEqual(location, preventedLocation)); 23 | 24 | export const shouldPrevent = (location: RRLocation): boolean => 25 | Boolean(location.state && location.state.preventLastLocation); 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types below are taken from utility-types package. 3 | * People who doesn't use TypeScript shouldn't be warned about missing `peerDependencies`. 4 | * This is the main reason why I didn't want to put utility-types into `peerDependencies`. 5 | * @source https://github.com/piotrwitek/utility-types 6 | */ 7 | 8 | /** 9 | * Assign 10 | * @desc From `U` assign properties to `T` (just like object assign) 11 | * @example 12 | * type Props = { name: string; age: number; visible: boolean }; 13 | * type NewProps = { age: string; other: string }; 14 | * 15 | * // Expect: { name: string; age: number; visible: boolean; other: string; } 16 | * type ExtendedProps = Assign; 17 | */ 18 | export type Assign< 19 | T extends object, 20 | U extends object, 21 | I = Diff & Intersection & Diff 22 | > = Pick; 23 | 24 | /** 25 | * Intersection 26 | * @desc From `T` pick properties that exist in `U` 27 | * @example 28 | * type Props = { name: string; age: number; visible: boolean }; 29 | * type DefaultProps = { age: number }; 30 | * 31 | * // Expect: { age: number; } 32 | * type DuplicateProps = Intersection; 33 | */ 34 | export type Intersection = Pick< 35 | T, 36 | Extract & Extract 37 | >; 38 | 39 | /** 40 | * Diff 41 | * @desc From `T` remove properties that exist in `U` 42 | * @example 43 | * type Props = { name: string; age: number; visible: boolean }; 44 | * type DefaultProps = { age: number }; 45 | * 46 | * // Expect: { name: string; visible: boolean; } 47 | * type DiffProps = Diff; 48 | */ 49 | export type Diff = Pick< 50 | T, 51 | SetDifference 52 | >; 53 | 54 | /** 55 | * Subtract 56 | * @desc From `T` remove properties that exist in `T1` (`T1` is a subtype of `T`) 57 | * @example 58 | * type Props = { name: string; age: number; visible: boolean }; 59 | * type DefaultProps = { age: number }; 60 | * 61 | * // Expect: { name: string; visible: boolean; } 62 | * type RestProps = Subtract; 63 | */ 64 | export type Subtract = Pick< 65 | T, 66 | SetComplement 67 | >; 68 | 69 | /** 70 | * SetDifference (same as Exclude) 71 | * @desc Set difference of given union types `A` and `B` 72 | * @example 73 | * // Expect: "1" 74 | * SetDifference<'1' | '2' | '3', '2' | '3' | '4'>; 75 | * 76 | * // Expect: string | number 77 | * SetDifference void), Function>; 78 | */ 79 | export type SetDifference = A extends B ? never : A; 80 | 81 | /** 82 | * SetComplement 83 | * @desc Set complement of given union types `A` and (it's subset) `A1` 84 | * @example 85 | * // Expect: "1" 86 | * SetComplement<'1' | '2' | '3', '2' | '3'>; 87 | */ 88 | export type SetComplement = SetDifference; 89 | -------------------------------------------------------------------------------- /src/useLastLocation.spec.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import * as React from 'react'; 3 | import { renderHook as rtlRenderHook, act } from '@testing-library/react-hooks'; 4 | import { Router } from 'react-router-dom'; 5 | import { createMemoryHistory } from 'history'; 6 | import useLastLocation from './useLastLocation'; 7 | import LastLocationProvider, { setLastLocation, getLastLocation } from './LastLocationProvider'; 8 | import { LastLocationType } from './LastLocationContext'; 9 | 10 | function renderHook(watchOnlyPathname = false) { 11 | const history = createMemoryHistory({ 12 | initialEntries: ['/'], 13 | initialIndex: 0, 14 | }); 15 | 16 | return { 17 | history, 18 | ...rtlRenderHook(() => useLastLocation(), { 19 | wrapper: ({ children }) => ( 20 | 21 | 22 | {children} 23 | 24 | 25 | ), 26 | }), 27 | }; 28 | } 29 | 30 | afterEach(() => setLastLocation(null)) 31 | describe('useLastLocation', () => { 32 | it('should have no last location on the first visit', async () => { 33 | const { result } = renderHook(); 34 | 35 | expect(result.current).toBe(null); 36 | }); 37 | 38 | it('Home ► About, should show / as last location', () => { 39 | const { history, result } = renderHook(); 40 | expect(result.current).toBe(null); 41 | 42 | act(() => { 43 | history.push('/about'); 44 | }); 45 | 46 | expect(result.current).not.toBe(null); 47 | // @ts-ignore 48 | expect(result.current.pathname).toBe('/'); 49 | }); 50 | 51 | it('Home ► About ► Contact, should show /about as last location', () => { 52 | const { history, result } = renderHook(); 53 | 54 | act(() => { 55 | history.push('/about'); 56 | }); 57 | 58 | expect(result.current).not.toBe(null); 59 | // @ts-ignore 60 | expect(result.current.pathname).toBe('/'); 61 | 62 | act(() => { 63 | history.push('/contact'); 64 | }); 65 | 66 | expect(result.current).not.toBe(null); 67 | // @ts-ignore 68 | expect(result.current.pathname).toBe('/about'); 69 | }); 70 | }); 71 | 72 | 73 | describe('When watchOnlyPathname is true', () => { 74 | it('should set lastLocation each time when pathname in location is changed', () => { 75 | const { history, result } = renderHook(true); 76 | 77 | act(() => { 78 | history.push('/test-1'); 79 | }); 80 | expect((result.current as any).pathname).toBe('/'); 81 | act(() => { 82 | history.push('/test-1?foo=bar'); 83 | }); 84 | expect((result.current as any).pathname).toBe('/'); 85 | act(() => { 86 | history.push('/test-1?foo=zoo'); 87 | }); 88 | expect((result.current as any).pathname).toBe('/'); 89 | }); 90 | }); 91 | 92 | it('should do nothing if application is rerendered and location is the same', () => { 93 | const { history, result, rerender } = renderHook(); 94 | act(() => { 95 | history.push('/test-1'); 96 | history.push('/test-2'); 97 | }); 98 | const getterLastPrev = getLastLocation() as Exclude 99 | const lastLocationPrev = (result.current as any).pathname; 100 | /** 101 | * This one is a bit tricky. I want to test case when `getDerivedStateFromProps` would be 102 | * called when location is not changing, e.g. any other prop is changing... 103 | * @see https://github.com/airbnb/enzyme/issues/1925#issuecomment-463248558 104 | */ 105 | rerender(); 106 | const getterLastNext = getLastLocation() as Exclude 107 | const lastLocationNext = (result.current as any).pathname; 108 | 109 | expect(getterLastPrev.key).toBe(getterLastNext.key) 110 | expect(getterLastPrev.pathname).toBe(getterLastNext.pathname) 111 | expect(lastLocationPrev).toBe(lastLocationNext); 112 | }); 113 | 114 | it('should NOT store redirected locations', () => { 115 | const { history, result } = renderHook(); 116 | 117 | act(() => { 118 | history.push('/test-1') 119 | ;}) 120 | expect((result.current as any).pathname).toBe('/'); 121 | 122 | act(() => { 123 | history.replace('/test-2', { preventLastLocation: true }) 124 | ;}) 125 | expect((result.current as any).pathname).toBe('/'); 126 | 127 | act(() => { 128 | history.replace('/test-3', { preventLastLocation: true }) 129 | ;}) 130 | expect((result.current as any).pathname).toBe('/'); 131 | 132 | act(() => { 133 | history.replace('/test-4') 134 | ;}) 135 | expect((result.current as any).pathname).toBe('/test-3'); 136 | }); 137 | -------------------------------------------------------------------------------- /src/useLastLocation.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import LastLocationContext from './LastLocationContext'; 3 | 4 | export default function useLastLocation() { 5 | return useContext(LastLocationContext); 6 | } 7 | -------------------------------------------------------------------------------- /src/withLastLocation.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import {render, screen} from '@testing-library/react'; 3 | import { createMemoryHistory } from 'history'; 4 | import { Router } from 'react-router-dom'; 5 | import withLastLocation from './withLastLocation'; 6 | import { getLastLocation } from './LastLocationProvider'; 7 | import { WithLastLocationProps } from '.'; 8 | 9 | const mockLocation = { 10 | pathname: '/testing-at-night', 11 | search: '', 12 | state: undefined, 13 | hash: undefined, 14 | key: '58sga71s', 15 | }; 16 | 17 | jest.mock('./LastLocationProvider', () => ({ 18 | getLastLocation: jest.fn(() => mockLocation), 19 | })); 20 | 21 | const mockedGetLastLocation = getLastLocation as jest.Mock>; 22 | 23 | const prepareTest = () => { 24 | const history = createMemoryHistory({initialEntries: ["/"]}) 25 | const TestComponent = () =>
Test
; 26 | const TestComponentWithLastLocation = withLastLocation(TestComponent); 27 | render( 28 | 29 | 30 | 31 | ); 32 | 33 | return { 34 | TestComponent, 35 | TestComponentWithLastLocation, 36 | history 37 | }; 38 | }; 39 | 40 | describe('withLastLocation', () => { 41 | it('should be a function', () => { 42 | expect(typeof withLastLocation).toBe('function'); 43 | }); 44 | 45 | describe('When a react component is passed as a parameter', () => { 46 | beforeEach(() => { 47 | jest.clearAllMocks(); 48 | }); 49 | 50 | it('should render the wrapped component', () => { 51 | prepareTest(); 52 | expect(screen.getByText("Test")).toBeInTheDocument(); 53 | }); 54 | 55 | it('should pass lastLocation as prop to the wrapped component', () => { 56 | const TestComponent = ({lastLocation}: WithLastLocationProps) =>
{lastLocation ? lastLocation.pathname : ""}
; 57 | const TestComponentWithLastLocation = withLastLocation(TestComponent); 58 | 59 | const history = createMemoryHistory({initialEntries: ["/"]}) 60 | render( 61 | 62 | 63 | 64 | ); 65 | 66 | expect(screen.getByText(mockLocation.pathname)).toBeInTheDocument(); 67 | }) 68 | 69 | it('should call getLastLocation when route is changed', () => { 70 | const { history } = prepareTest(); 71 | expect(mockedGetLastLocation.mock.calls.length).toBe(1); 72 | history.push('/saturday-night'); 73 | expect(mockedGetLastLocation.mock.calls.length).toBe(2); 74 | }); 75 | 76 | it('should set displayName for the parent component', () => { 77 | const TestComponent = () =>
Test
; 78 | const TestComponentWithLastLocation = withLastLocation(TestComponent); 79 | expect(TestComponentWithLastLocation.displayName).toBe('withRouter(WithLastLocation(TestComponent))'); 80 | }); 81 | 82 | it('should set wrapped component name to Component on anonymous components', () => { 83 | const TestComponentWithLastLocation = withLastLocation(() =>
Test
); 84 | expect(TestComponentWithLastLocation.displayName).toBe('withRouter(WithLastLocation(Component))'); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/withLastLocation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 3 | import { getLastLocation } from './LastLocationProvider'; 4 | import { Subtract } from './types'; 5 | 6 | function getDisplayName

(WrappedComponent: React.ComponentType

) { 7 | return WrappedComponent.displayName || WrappedComponent.name || 'Component'; 8 | } 9 | 10 | export interface WithLastLocationProps extends RouteComponentProps { 11 | lastLocation: ReturnType; 12 | } 13 | 14 | const withLastLocation = ( 15 | WrappedComponent: React.ComponentType 16 | ) => { 17 | type HocProps = Subtract & RouteComponentProps; 18 | 19 | const WithLastLocation: React.FC = props => ( 20 | 21 | ); 22 | 23 | WithLastLocation.displayName = `WithLastLocation(${getDisplayName(WrappedComponent)})`; 24 | 25 | return withRouter(WithLastLocation); 26 | }; 27 | 28 | export default withLastLocation; 29 | -------------------------------------------------------------------------------- /tests/setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "strict": true, 5 | "sourceMap": true, 6 | "module": "commonjs", 7 | "target": "es5", 8 | "jsx": "react", 9 | "allowJs": true 10 | }, 11 | "compileOnSave": false 12 | } 13 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const webpackMerge = require('webpack-merge'); 3 | const path = require('path'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | 7 | const tsLoader = nextLoader => ({ 8 | test: /\.(ts|tsx|js|)$/, 9 | use: [ 10 | { 11 | loader: 'ts-loader', 12 | options: { 13 | onlyCompileBundledFiles: true, 14 | }, 15 | }, 16 | nextLoader, 17 | ].filter(Boolean), 18 | exclude: [/node_modules/, /\.ejs$/], 19 | }); 20 | 21 | module.exports = (env) => { 22 | const isProd = env.prod === true; 23 | const isExample = env.example === true; 24 | const type = isExample ? 'example' : 'lib'; 25 | 26 | // eslint-disable-next-line no-console 27 | console.log(`Compiling: ${type} in mode: ${isProd ? 'production' : 'dev'}`); 28 | 29 | const entries = { 30 | example: './example/src/index.tsx', 31 | lib: './src/index.ts', 32 | }; 33 | 34 | const outputPaths = { 35 | example: path.resolve(__dirname, 'example/dist'), 36 | lib: path.resolve(__dirname, 'dist'), 37 | }; 38 | 39 | const config = { 40 | entry: entries[type], 41 | resolve: { 42 | extensions: ['.tsx', '.ts', '.js'], 43 | }, 44 | output: { 45 | path: outputPaths[type], 46 | filename: 'index.js', 47 | }, 48 | }; 49 | 50 | if (isProd) { 51 | return webpackMerge(config, { 52 | module: { 53 | rules: [tsLoader()], 54 | }, 55 | mode: 'production', 56 | optimization: { 57 | minimize: false, 58 | }, 59 | output: { 60 | library: 'ReactRouterLastLocation', 61 | libraryTarget: 'umd', 62 | /** 63 | * When targeting a library, especially the libraryTarget is 'umd', 64 | * this option indicates what global object will be used to mount the library. 65 | * To make UMD build available on both browsers and Node.js, 66 | * set output.globalObject option to 'this'. 67 | * @see https://webpack.js.org/configuration/output/#outputglobalobject 68 | */ 69 | globalObject: 'this', 70 | }, 71 | // @see https://github.com/webpack/webpack/issues/603#issuecomment-215547651 72 | externals: /^[^.]/, 73 | plugins: [ 74 | new webpack.DefinePlugin({ 75 | 'process.env': { NODE_ENV: JSON.stringify(process.env.NODE_ENV) }, 76 | }), 77 | ], 78 | }); 79 | } 80 | 81 | return webpackMerge(config, { 82 | module: { 83 | rules: [ 84 | tsLoader({ 85 | loader: 'linaria/loader', 86 | options: { 87 | sourceMap: true, 88 | }, 89 | }), 90 | { 91 | test: /\.css$/, 92 | use: [ 93 | 'css-hot-loader', 94 | MiniCssExtractPlugin.loader, 95 | { 96 | loader: 'css-loader', 97 | options: { 98 | modules: 'global', 99 | sourceMap: true, 100 | }, 101 | }, 102 | ], 103 | }, 104 | ], 105 | }, 106 | mode: 'development', 107 | devtool: 'cheap-module-eval-source-map', 108 | devServer: { 109 | contentBase: outputPaths.example, 110 | compress: true, 111 | hot: true, 112 | port: 8080, 113 | historyApiFallback: { disableDotRule: true }, 114 | }, 115 | plugins: [ 116 | new HtmlWebpackPlugin({ 117 | template: './example/src/index.ejs', 118 | }), 119 | new MiniCssExtractPlugin({ 120 | filename: 'styles.css', 121 | }), 122 | new webpack.HotModuleReplacementPlugin(), 123 | ], 124 | }); 125 | }; 126 | --------------------------------------------------------------------------------