├── .browserslistrc ├── .eslintignore ├── .eslintrc ├── .github ├── lock.yml └── workflows │ ├── format.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── README.md ├── api-reference.md ├── blocking-transitions.md ├── getting-started.md ├── images │ └── block.png ├── installation.md └── navigation.md ├── fixtures ├── block-library │ ├── index.html │ └── index.js ├── block-vanilla │ ├── index.html │ └── index.js ├── hash-click.html ├── hash-history-length.html └── unpkg-test.html ├── package-lock.json ├── package.json ├── packages └── history │ ├── .eslintrc │ ├── __tests__ │ ├── .eslintrc │ ├── TestSequences │ │ ├── BackButtonTransitionHook.js │ │ ├── BlockEverything.js │ │ ├── BlockPopWithoutListening.js │ │ ├── EncodedReservedCharacters.js │ │ ├── GoBack.js │ │ ├── GoForward.js │ │ ├── InitialLocationDefaultKey.js │ │ ├── InitialLocationHasKey.js │ │ ├── Listen.js │ │ ├── PushMissingPathname.js │ │ ├── PushNewLocation.js │ │ ├── PushRelativePathname.js │ │ ├── PushRelativePathnameWarning.js │ │ ├── PushSamePath.js │ │ ├── PushState.js │ │ ├── ReplaceNewLocation.js │ │ ├── ReplaceSamePath.js │ │ ├── ReplaceState.js │ │ └── utils.js │ ├── browser-test.js │ ├── create-path-test.js │ ├── hash-base-test.js │ ├── hash-test.js │ └── memory-test.js │ ├── browser.ts │ ├── hash.ts │ ├── index.ts │ ├── node-main.js │ └── package.json ├── scripts ├── build.js ├── karma.conf.js ├── publish.js ├── rollup │ └── history.config.js ├── test.js ├── tests.webpack.js └── version.js ├── tsconfig.json └── types └── global.d.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers we support 2 | > 0.5% 3 | Chrome >= 73 4 | ChromeAndroid >= 75 5 | Firefox >= 67 6 | Edge >= 17 7 | IE 11 8 | Safari >= 12.1 9 | iOS >= 11.3 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /build 2 | /fixtures 3 | 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "rules": { 4 | "import/no-anonymous-default-export": 0 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/lock.yml: -------------------------------------------------------------------------------- 1 | # Configuration for lock-threads - https://github.com/dessant/lock-threads 2 | 3 | # Number of days of inactivity before a closed issue or pull request is locked 4 | daysUntilLock: 60 5 | 6 | # Issues and pull requests with these labels will not be locked. Set to `[]` to disable 7 | exemptLabels: [] 8 | 9 | # Label to add before locking, such as `outdated`. Set to `false` to disable 10 | lockLabel: false 11 | 12 | # Comment to post before locking. Set to `false` to disable 13 | lockComment: false 14 | # Limit to only `issues` or `pulls` 15 | # only: issues 16 | 17 | # Optionally, specify configuration settings just for `issues` or `pulls` 18 | # issues: 19 | # exemptLabels: 20 | # - help-wanted 21 | # lockLabel: outdated 22 | 23 | # pulls: 24 | # daysUntilLock: 30 25 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | format: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Cancel Previous Runs 14 | uses: styfle/cancel-workflow-action@0.9.1 15 | 16 | - name: Checkout Repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Use Node.js 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: 14 23 | 24 | - name: Install dependencies 25 | run: yarn install --frozen-lockfile 26 | 27 | - name: Format 28 | run: npm run format --if-present 29 | 30 | - name: Commit 31 | run: | 32 | git config --local user.email "github-actions@remix.run" 33 | git config --local user.name "Remix Run Bot" 34 | 35 | git add . 36 | if [ -z "$(git status --porcelain)" ]; then 37 | echo "💿 no formatting changed" 38 | exit 0 39 | fi 40 | git commit -m "chore: format" -m "formatted $GITHUB_SHA" 41 | git push 42 | echo "💿 pushed formatting changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" 43 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | 6 | jobs: 7 | release: 8 | if: github.repository == 'remix-run/history' 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | # https://github.com/actions/setup-node/issues/213#issuecomment-833724757 24 | - name: Install npm v7 25 | run: npm i -g npm@7 --registry=https://registry.npmjs.org 26 | 27 | - name: Install dependencies 28 | run: npm ci 29 | 30 | - name: Build 31 | run: npm run build 32 | 33 | - name: Test 34 | run: npm run test & npm run size 35 | 36 | - name: Publish 37 | env: 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | run: | 40 | echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc 41 | node scripts/publish.js 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | 8 | strategy: 9 | matrix: 10 | node-version: [14.x] 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | # https://github.com/actions/setup-node/issues/213#issuecomment-833724757 21 | - name: Install npm v7 22 | run: npm i -g npm@7 --registry=https://registry.npmjs.org 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Build 28 | run: npm run build 29 | 30 | - name: Test 31 | run: npm run test & npm run size 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /fixtures/*/history.js 3 | 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | All development happens [on GitHub](https://github.com/remix-run/history). When [creating a pull request](https://help.github.com/articles/creating-a-pull-request/), please make sure that all of the following apply: 2 | 3 | - If you're adding a new feature or "fixing" a bug, please go into detail about your use case and why you think you need that feature or that bug fixed. This library has lots and lots of dependents, and we can't afford to make changes lightly without understanding why. 4 | - The tests pass. The test suite will automatically run when you create the PR. All you need to do is wait a few minutes to see the result in the PR. 5 | - Each commit in your PR should describe a significant piece of work. Work that was done in one commit and then undone later should be squashed into a single commit. 6 | 7 | If your PR fails to meet these guidelines, it may be closed without much of an explanation besides a link to this document. 8 | 9 | Thank you for your contribution! 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) React Training 2016-2020 4 | Copyright (c) Remix Software 2020-2021 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # history · [![npm package][npm-badge]][npm] 2 | 3 | [npm-badge]: https://img.shields.io/npm/v/history.svg?style=flat-square 4 | [npm]: https://www.npmjs.org/package/history 5 | 6 | The history library lets you easily manage session history anywhere JavaScript runs. A `history` object abstracts away the differences in various environments and provides a minimal API that lets you manage the history stack, navigate, and persist state between sessions. 7 | 8 | ## Documentation 9 | 10 | Documentation for version 5 can be found in the [docs](docs) directory. This is the current stable release. Version 5 is used in React Router version 6. 11 | 12 | Documentation for version 4 can be found [on the v4 branch](https://github.com/remix-run/history/tree/v4/docs). Version 4 is used in React Router versions 4 and 5. 13 | 14 | ## Changes 15 | 16 | To see the changes that were made in a given release, please lookup the tag on [the releases page](https://github.com/remix-run/history/releases). 17 | 18 | For changes released in version 4.6.3 and earlier, please see [the `CHANGES.md` file](https://github.com/remix-run/history/blob/845d690c5576c7f55ecbe14babe0092e8e5bc2bb/CHANGES.md). 19 | 20 | ## Development 21 | 22 | Development of the current stable release, version 5, happens on [the `main` branch](https://github.com/remix-run/history/tree/main). Please keep in mind that this branch may include some work that has not yet been published as part of an official release. However, since `main` is always stable, you should feel free to build your own working release straight from `main` at any time. 23 | 24 | If you're interested in helping out, please read [our contributing guidelines](CONTRIBUTING.md). 25 | 26 | ## About 27 | 28 | `history` is developed and maintained by [Remix](https://remix.run). If you're interested in learning more about what React can do for your company, please [get in touch](mailto:hello@remix.run)! 29 | 30 | ## Thanks 31 | 32 | A big thank-you to [BrowserStack](https://www.browserstack.com/) for providing the infrastructure that allows us to run our build in real browsers. 33 | 34 | Also, thanks to [Dan Shaw](https://www.npmjs.com/~dshaw) for letting us use the `history` npm package name. Thanks, Dan! 35 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Welcome to the history docs! 2 | 3 | The history library lets you easily manage session history anywhere JavaScript 4 | runs. A `history` object abstracts away the differences in various environments 5 | and provides a minimal API that lets you manage the history stack, navigate, and 6 | persist state between sessions. 7 | 8 | The library is very small, so there are really just a few files here for you to 9 | browse. 10 | 11 | If this is your first time here, we'd recommend you start with the [Getting 12 | Started](getting-started.md) guide. 13 | 14 | You may also be interested in reading one of the following guides: 15 | 16 | - [Installation](installation.md) 17 | - [Navigation](navigation.md) 18 | - [Blocking Transitions](blocking-transitions.md) 19 | 20 | An [API Reference](api-reference.md) is also available. 21 | -------------------------------------------------------------------------------- /docs/api-reference.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # history API Reference 4 | 5 | This is the API reference for [the history JavaScript library](https://github.com/remix-run/history). 6 | 7 | The history library provides an API for tracking application history using [location](#location) objects that contain URLs and state. This reference includes type signatures and return values for the interfaces in the library. Please read the [getting started guide](getting-started.md) if you're looking for explanations about how to use the library to accomplish a specific task. 8 | 9 | 10 | 11 | ## Overview 12 | 13 | 14 | 15 | ### Environments 16 | 17 | The history library includes support for three different "environments", or modes of operation. 18 | 19 | - [Browser history](#createbrowserhistory) is used in web apps 20 | - [Hash history](#createhashhistory) is used in web apps where you don't want to/can't send the URL to the server for some reason 21 | - [Memory history](#creatememoryhistory) - is used in native apps and testing 22 | 23 | Just pick the right mode for your target environment and you're good to go. 24 | 25 | 26 | 27 | ### Listening 28 | 29 | To read the current location and action, use [`history.location`](#history.location) and [`history.action`](#history.action). Both of these properties are mutable and automatically update as the location changes. 30 | 31 | To be notified when the location changes, setup a listener using [`history.listen`](#history.listen). 32 | 33 | 34 | 35 | ### Navigation 36 | 37 | To change the current location, you'll want to use one of the following: 38 | 39 | - [`history.push`](#history.push) - Pushes a new location onto the history stack 40 | - [`history.replace`](#history.replace) - Replaces the current location with another 41 | - [`history.go`](#history.go) - Changes the current index in the history stack by a given delta 42 | - [`history.back`](#history.back) - Navigates one entry back in the history stack 43 | - [`history.forward`](#history.forward) - Navigates one entry forward in the history stack 44 | 45 | 46 | 47 | ### Confirming Navigation 48 | 49 | To prevent the location from changing, use [`history.block`](#history.block). This API allows you to prevent the location from changing so you can prompt the user before retrying the transition. 50 | 51 | 52 | 53 | ### Creating `href` values 54 | 55 | If you're building a link, you'll want to use [`history.createHref`](#history.createhref) to get a URL you can use as the value of ``. 56 | 57 | --- 58 | 59 | 60 | 61 | ## Reference 62 | 63 | The [source code](https://github.com/remix-run/history/tree/main/packages/history) for the history library is written in TypeScript, but it is compiled to JavaScript before publishing. Some of the function signatures in this reference include their TypeScript type annotations, but you can always refer to the original source as well. 64 | 65 | 66 | 67 | ### Action 68 | 69 | An `Action` represents a type of change that occurred in the history stack. `Action` is an `enum` with three members: 70 | 71 | - `Action.Pop` - A change to an arbitrary index in the stack, such as a back or forward navigation. This does not describe the direction of the navigation, only that the index changed. This is the default action for newly created history objects. 72 | - `Action.Push` - Indicates a new entry being added to the history stack, such as when a link is clicked and a new page loads. When this happens, all subsequent entries in the stack are lost. 73 | - `Action.Replace` - Indicates the entry at the current index in the history stack being replaced by a new one. 74 | 75 | See [the Getting Started guide](getting-started.md) for more information. 76 | 77 | 78 | 79 | 80 | ### `History` 81 | 82 | A `History` object represents the shared interface for `BrowserHistory`, `HashHistory`, and `MemoryHistory`. 83 | 84 |
85 | Type declaration 86 | 87 | ```ts 88 | interface History { 89 | readonly action: Action; 90 | readonly location: Location; 91 | createHref(to: To): string; 92 | push(to: To, state?: any): void; 93 | replace(to: To, state?: any): void; 94 | go(delta: number): void; 95 | back(): void; 96 | forward(): void; 97 | listen(listener: Listener): () => void; 98 | block(blocker: Blocker): () => void; 99 | } 100 | ``` 101 | 102 |
103 | 104 | ### `createBrowserHistory` 105 | 106 |
107 | Type declaration 108 | 109 | ```tsx 110 | function createBrowserHistory(options?: { window?: Window }): BrowserHistory; 111 | 112 | interface BrowserHistory extends History {} 113 | ``` 114 | 115 |
116 | 117 | A browser history object keeps track of the browsing history of an application using the browser's built-in history stack. It is designed to run in modern web browsers that support the HTML5 history interface including `pushState`, `replaceState`, and the `popstate` event. 118 | 119 | `createBrowserHistory` returns a `BrowserHistory` instance. `window` defaults to [the `defaultView` of the current `document`](https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView). 120 | 121 | ```ts 122 | import { createBrowserHistory } from "history"; 123 | let history = createBrowserHistory(); 124 | ``` 125 | 126 | See [the Getting Started guide](getting-started.md) for more information. 127 | 128 | 129 | 130 | 131 | 132 | ### `createPath` and `parsePath` 133 | 134 |
135 | Type declaration 136 | 137 | ```ts 138 | function createPath(partialPath: Partial): string; 139 | function parsePath(path: string): Partial; 140 | 141 | interface Path { 142 | pathname: string; 143 | search: string; 144 | hash: string; 145 | } 146 | ``` 147 | 148 |
149 | 150 | The `createPath` and `parsePath` functions are useful for creating and parsing URL paths. 151 | 152 | ```ts 153 | createPath({ pathname: "/login", search: "?next=home" }); // "/login?next=home" 154 | parsePath("/login?next=home"); // { pathname: '/login', search: '?next=home' } 155 | ``` 156 | 157 | 158 | 159 | 160 | ### `createHashHistory` 161 | 162 |
163 | Type declaration 164 | 165 | ```ts 166 | createHashHistory({ window?: Window }): HashHistory; 167 | 168 | interface HashHistory extends History {} 169 | ``` 170 | 171 |
172 | 173 | A hash history object keeps track of the browsing history of an application using the browser's built-in history stack. It is designed to be run in modern web browsers that support the HTML5 history interface including `pushState`, `replaceState`, and the `popstate` event. 174 | 175 | `createHashHistory` returns a `HashHistory` instance. `window` defaults to [the `defaultView` of the current `document`](https://developer.mozilla.org/en-US/docs/Web/API/Document/defaultView). 176 | 177 | The main difference between this and [browser history](#createbrowserhistory) is that a hash history stores the current location in the [`hash` portion of the URL](https://developer.mozilla.org/en-US/docs/Web/API/Location/hash#:~:text=The%20hash%20property%20of%20the,an%20empty%20string%2C%20%22%22%20.), which means that it is not ever sent to the server. This can be useful if you are hosting your site on a domain where you do not have full control over the server routes, or e.g. in an Electron app where you don't want to configure the "server" to serve the same page at different URLs. 178 | 179 | ```ts 180 | import { createHashHistory } from "history"; 181 | let history = createHashHistory(); 182 | ``` 183 | 184 | See [the Getting Started guide](getting-started.md) for more information. 185 | 186 | 187 | 188 | 189 | ### `createMemoryHistory` 190 | 191 |
192 | Type declaration 193 | 194 | ```ts 195 | function createMemoryHistory({ 196 | initialEntries?: InitialEntry[], 197 | initialIndex?: number 198 | }): MemoryHistory; 199 | 200 | type InitialEntry = string | Partial; 201 | 202 | interface MemoryHistory extends History { 203 | readonly index: number; 204 | } 205 | ``` 206 | 207 |
208 | 209 | A memory history object keeps track of the browsing history of an application using an internal array. This makes it ideal in situations where you need complete control over the history stack, like React Native and tests. 210 | 211 | `createMemoryHistory` returns a `MemoryHistory` instance. You can provide initial entries to this history instance through the `initialEntries` property, which defaults to `['/']` (a single location at the root `/` URL). The `initialIndex` defaults to the index of the last item in `initialEntries`. 212 | 213 | ```ts 214 | import { createMemoryHistory } from "history"; 215 | let history = createMemoryHistory(); 216 | // Or, to pre-seed the history instance with some URLs: 217 | let history = createMemoryHistory({ 218 | initialEntries: ["/home", "/profile", "/about"], 219 | }); 220 | ``` 221 | 222 | See [the Getting Started guide](getting-started.md) for more information. 223 | 224 | 225 | 226 | ### `history.action` 227 | 228 | The current (most recent) [`Action`](#action) that modified the history stack. This property is mutable and automatically updates as the current location changes. 229 | 230 | See also [`history.listen`](#history.listen). 231 | 232 | 233 | 234 | ### `history.back()` 235 | 236 | Goes back one entry in the history stack. Alias for `history.go(-1)`. 237 | 238 | See [the Navigation guide](navigation.md) for more information. 239 | 240 | 241 | 242 | ### `history.block(blocker: Blocker)` 243 | 244 |
245 | Type declaration 246 | 247 | ```ts 248 | interface Blocker { 249 | (tx: Transition): void; 250 | } 251 | 252 | interface Transition { 253 | action: Action; 254 | location: Location; 255 | retry(): void; 256 | } 257 | ``` 258 | 259 |
260 | 261 | Prevents changes to the history stack from happening. This is useful when you want to prevent the user navigating away from the current page, for example when they have some unsaved data on the current page. 262 | 263 | ```ts 264 | // To start blocking location changes... 265 | let unblock = history.block(({ action, location, retry }) => { 266 | // A transition was blocked! 267 | }); 268 | 269 | // Later, when you want to start allowing transitions again... 270 | unblock(); 271 | ``` 272 | 273 | See [the guide on Blocking Transitions](blocking-transitions.md) for more information. 274 | 275 | 276 | 277 | ### `history.createHref(to: To)` 278 | 279 | Returns a string suitable for use as an `` value that will navigate to 280 | the given destination. 281 | 282 | 283 | 284 | ### `history.forward()` 285 | 286 | Goes forward one entry in the history stack. Alias for `history.go(1)`. 287 | 288 | See [the Navigation guide](navigation.md) for more information. 289 | 290 | 291 | 292 | ### `history.go(delta: number)` 293 | 294 | Navigates back/forward by `delta` entries in the stack. 295 | 296 | See [the Navigation guide](navigation.md) for more information. 297 | 298 | 299 | 300 | ### `history.index` 301 | 302 | The current index in the history stack. 303 | 304 | > [!Note:] 305 | > 306 | > This property is available only on [memory history](#memoryhistory) instances. 307 | 308 | 309 | 310 | ### `history.listen(listener: Listener)` 311 | 312 |
313 | Type declaration 314 | 315 | ```ts 316 | interface Listener { 317 | (update: Update): void; 318 | } 319 | 320 | interface Update { 321 | action: Action; 322 | location: Location; 323 | } 324 | ``` 325 | 326 |
327 | 328 | Starts listening for location changes and calls the given callback with an `Update` when it does. 329 | 330 | ```ts 331 | // To start listening for location changes... 332 | let unlisten = history.listen(({ action, location }) => { 333 | // The current location changed. 334 | }); 335 | 336 | // Later, when you are done listening for changes... 337 | unlisten(); 338 | ``` 339 | 340 | See [the Getting Started guide](getting-started.md#listening) for more information. 341 | 342 | 343 | 344 | ### `history.location` 345 | 346 | The current [`Location`](#location). This property is mutable and automatically updates as the current location changes. 347 | 348 | Also see [`history.listen`](#history.listen). 349 | 350 | 351 | 352 | ### `history.push(to: To, state?: any)` 353 | 354 | Pushes a new entry onto the stack. 355 | 356 | See [the Navigation guide](navigation.md) for more information. 357 | 358 | 359 | 360 | ### `history.replace(to: To, state?: any)` 361 | 362 | Replaces the current entry in the stack with a new one. 363 | 364 | See [the Navigation guide](navigation.md) for more information. 365 | 366 | 367 | 368 | ### Location 369 | 370 |
371 | Type declaration 372 | 373 | ```ts 374 | interface Location { 375 | pathname: string; 376 | search: string; 377 | hash: string; 378 | state: unknown; 379 | key: string; 380 | } 381 | ``` 382 | 383 |
384 | 385 | A `location` is a particular entry in the history stack, usually analogous to a "page" or "screen" in your app. As the user clicks on links and moves around the app, the current location changes. 386 | 387 | 388 | 389 | ### `location.pathname` 390 | 391 | The `location.pathname` property is a string that contains an initial `/` followed by the remainder of the URL up to the `?`. 392 | 393 | See also [`URL.pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname). 394 | 395 | 396 | 397 | ### `location.search` 398 | 399 | The `location.search` property is a string that contains an initial `?` followed by the `key=value` pairs in the query string. If there are no parameters, this value may be the empty string (i.e. `''`). 400 | 401 | See also [`URL.search`](https://developer.mozilla.org/en-US/docs/Web/API/URL/search). 402 | 403 | 404 | 405 | ### `location.hash` 406 | 407 | The `location.hash` property is a string that contains an initial `#` followed by fragment identifier of the URL. If there is no fragment identifier, this value may be the empty string (i.e. `''`). 408 | 409 | See also [`URL.hash`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hash). 410 | 411 | 412 | 413 | ### `location.state` 414 | 415 | The `location.state` property is a user-supplied [`State`](#state) object that is associated with this location. This can be a useful place to store any information you do not want to put in the URL, e.g. session-specific data. 416 | 417 | > [!Note:] 418 | > 419 | > In web browsers, this state is managed using the browser's built-in 420 | > `pushState`, `replaceState`, and `popstate` APIs. See also 421 | > [`History.state`](https://developer.mozilla.org/en-US/docs/Web/API/History/state). 422 | 423 | 424 | 425 | ### `location.key` 426 | 427 | The `location.key` property is a unique string associated with this location. On the initial location, this will be the string `default`. On all subsequent locations, this string will be a unique identifier. 428 | 429 | This can be useful in situations where you need to keep track of 2 different states for the same URL. For example, you could use this as the key to some network or device storage API. 430 | 431 | 432 | 433 | ### State 434 | 435 | A `State` value is an arbitrary value that holds extra information associated with a [`Location`](#location) but does not appear in the URL. This value is always associated with that location. 436 | 437 | See [the Navigation guide](navigation.md) for more information. 438 | 439 | 440 | 441 | ### To 442 | 443 |
444 | Type declaration 445 | 446 | ```ts 447 | type To = string | Partial; 448 | 449 | interface Path { 450 | pathname: string; 451 | search: string; 452 | hash: string; 453 | } 454 | ``` 455 | 456 |
457 | 458 | A [`To`](https://github.com/remix-run/history/blob/main/packages/history/index.ts#L178) value represents a destination location, but doesn't contain all the information that a normal [`location`](#location) object does. It is primarily used as the first argument to [`history.push`](#history.push) and [`history.replace`](#history.replace). 459 | 460 | See [the Navigation guide](navigation.md) for more information. 461 | -------------------------------------------------------------------------------- /docs/blocking-transitions.md: -------------------------------------------------------------------------------- 1 | # Blocking Transitions 2 | 3 | `history` lets you block navigation away from the current page using the 4 | [`history.block(blocker: Blocker)`](api-reference.md#history.block) API. For 5 | example, you can make sure the user knows that if they leave the current page 6 | they will lose some unsaved changes they've made. 7 | 8 | ```js 9 | // Block navigation and register a callback that 10 | // fires when a navigation attempt is blocked. 11 | let unblock = history.block((tx) => { 12 | // Navigation was blocked! Let's show a confirmation dialog 13 | // so the user can decide if they actually want to navigate 14 | // away and discard changes they've made in the current page. 15 | let url = tx.location.pathname; 16 | if (window.confirm(`Are you sure you want to go to ${url}?`)) { 17 | // Unblock the navigation. 18 | unblock(); 19 | 20 | // Retry the transition. 21 | tx.retry(); 22 | } 23 | }); 24 | ``` 25 | 26 | This example uses 27 | [`window.confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), 28 | but you could also use your own custom confirm dialog if you'd prefer. 29 | 30 | ## Caveats 31 | 32 | `history.block` will call your callback for all in-page navigation attempts, but 33 | for navigation that reloads the page (e.g. the refresh button or a link that 34 | doesn't use `history.push`) it registers [a `beforeunload` 35 | handler](https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event) 36 | to prevent the navigation. In modern browsers you are not able to customize this 37 | dialog. Instead, you'll see something like this (Chrome): 38 | 39 | ![Chrome navigation confirm dialog](images/block.png) 40 | 41 | One subtle side effect of registering a `beforeunload` handler is that the page 42 | will not be [salvageable](https://html.spec.whatwg.org/#unloading-documents) in 43 | [the `pagehide` 44 | event](https://developer.mozilla.org/en-US/docs/Web/API/Window/pagehide_event). 45 | This means the page may not be reused by the browser, so things like timers, 46 | scroll position, and event sources will not be reused when the user navigates 47 | back to that page. 48 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Intro 5 | 6 | The history library provides history tracking and navigation primitives for JavaScript applications that run in browsers and other stateful environments. 7 | 8 | If you haven't yet, please take a second to read through [the Installation guide](installation.md) to get the library installed and running on your system. 9 | 10 | We provide 3 different methods for working with history, depending on your environment: 11 | 12 | - A "browser history" is for use in modern web browsers that support the [HTML5 history API](http://diveintohtml5.info/history.html) (see [cross-browser compatibility](http://caniuse.com/#feat=history)) 13 | - A "hash history" is for use in web browsers where you want to store the location in the [hash](https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/hash) portion of the current URL to avoid sending it to the server when the page reloads 14 | - A "memory history" is used as a reference implementation that may be used in non-browser environments, like [React Native](https://facebook.github.io/react-native/) or tests 15 | 16 | The main bundle exports one method for each environment: [`createBrowserHistory`](api-reference.md#createbrowserhistory) for browsers, [`createHashHistory`](api-reference.md#createhashhistory) for using hash history in browsers, and [`createMemoryHistory`](api-reference.md#creatememoryhistory) for creating an in-memory history. 17 | 18 | In addition to the main bundle, the library also includes `history/browser` and `history/hash` bundles that export singletons you can use to quickly get a history instance for [the current `document`](https://developer.mozilla.org/en-US/docs/Web/API/Window/document) (web page). 19 | 20 | ## Basic Usage 21 | 22 | Basic usage looks like this: 23 | 24 | ```js 25 | // Create your own history instance. 26 | import { createBrowserHistory } from "history"; 27 | let history = createBrowserHistory(); 28 | 29 | // ... or just import the browser history singleton instance. 30 | import history from "history/browser"; 31 | 32 | // Alternatively, if you're using hash history import 33 | // the hash history singleton instance. 34 | // import history from 'history/hash'; 35 | 36 | // Get the current location. 37 | let location = history.location; 38 | 39 | // Listen for changes to the current location. 40 | let unlisten = history.listen(({ location, action }) => { 41 | console.log(action, location.pathname, location.state); 42 | }); 43 | 44 | // Use push to push a new entry onto the history stack. 45 | history.push("/home", { some: "state" }); 46 | 47 | // Use replace to replace the current entry in the stack. 48 | history.replace("/logged-in"); 49 | 50 | // Use back/forward to navigate one entry back or forward. 51 | history.back(); 52 | 53 | // To stop listening, call the function returned from listen(). 54 | unlisten(); 55 | ``` 56 | 57 | If you're using memory history you'll need to create your own `history` object 58 | before you can use it. 59 | 60 | ```js 61 | import { createMemoryHistory } from "history"; 62 | let history = createMemoryHistory(); 63 | ``` 64 | 65 | If you're using browser or hash history with a `window` other than that of the 66 | current `document` (like an iframe), you'll need to create your own browser/hash 67 | history: 68 | 69 | ```js 70 | import { createBrowserHistory } from "history"; 71 | let history = createBrowserHistory({ 72 | window: iframe.contentWindow, 73 | }); 74 | ``` 75 | 76 | 77 | 78 | ## Properties 79 | 80 | Each `history` object has the following properties: 81 | 82 | - [`history.location`](api-reference.md#history.location) - The current location (see below) 83 | - [`history.action`](api-reference.md#history.action) - The current navigation action (see below) 84 | 85 | Additionally, memory history provides `history.index` that tells you the current index in the history stack. 86 | 87 | 88 | 89 | ## Listening 90 | 91 | You can listen for changes to the current location using `history.listen`: 92 | 93 | ```js 94 | history.listen(({ action, location }) => { 95 | console.log( 96 | `The current URL is ${location.pathname}${location.search}${location.hash}` 97 | ); 98 | console.log(`The last navigation action was ${action}`); 99 | }); 100 | ``` 101 | 102 | The [`location`](api-reference.md#location) object implements a subset of [the `window.location` interface](https://developer.mozilla.org/en-US/docs/Web/API/Location), including: 103 | 104 | - [`location.pathname`](api-reference.md#location.pathname) - The path of the URL 105 | - [`location.search`](api-reference.md#location.search) - The URL query string 106 | - [`location.hash`](api-reference.md#location.hash) - The URL hash fragment 107 | - [`location.state`](api-reference.md#location.state) - Some extra state for this 108 | location that does not reside in the URL (may be `null`) 109 | - [`location.key`](api-reference.md#location.key) - A unique string representing this location 110 | 111 | The [`action`](api-reference.md#action) is one of `Action.Push`, `Action.Replace`, or `Action.Pop` depending on how the user got to the current location. 112 | 113 | - `Action.Push` means one more entry was added to the history stack 114 | - `Action.Replace` means the current entry in the stack was replaced 115 | - `Action.Pop` means we went to some other location already in the stack 116 | 117 | 118 | 119 | ## Cleaning up 120 | 121 | When you attach a listener using `history.listen`, it returns a function that can be used to remove the listener, which can then be invoked in cleanup logic: 122 | 123 | ```js 124 | let unlisten = history.listen(myListener); 125 | 126 | // Later, when you're done... 127 | unlisten(); 128 | ``` 129 | 130 | 131 | 132 | ## Utilities 133 | 134 | The main history bundle also contains both `createPath` and `parsePath` methods that may be useful when working with URL paths. 135 | 136 | ```js 137 | let pathPieces = parsePath("/the/path?the=query#the-hash"); 138 | // pathPieces = { 139 | // pathname: '/the/path', 140 | // search: '?the=query', 141 | // hash: '#the-hash' 142 | // } 143 | 144 | let path = createPath(pathPieces); 145 | // path = '/the/path?the=query#the-hash' 146 | ``` 147 | -------------------------------------------------------------------------------- /docs/images/block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remix-run/history/3e9dab413f4eda8d6bce565388c5ddb7aeff9f7e/docs/images/block.png -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | The history library is published to the public [npm](https://www.npmjs.com/) 4 | registry. You can install it using: 5 | 6 | $ npm install --save history 7 | 8 | ## Using a Bundler 9 | 10 | The best way to use the `history` library is with a bundler that supports 11 | JavaScript modules (we recommend [Rollup](https://rollupjs.org)). Recent 12 | versions of Webpack and Parcel are also good choices. 13 | 14 | Then you can write your code using JavaScript `import` statements, like this: 15 | 16 | ```js 17 | import { createBrowserHistory } from "history"; 18 | // ... 19 | ``` 20 | 21 | If you're using a bundler that doesn't understand JavaScript modules and only 22 | understands CommonJS, you can use `require` as you would with anything else: 23 | 24 | ```js 25 | var createBrowserHistory = require("history").createBrowserHistory; 26 | ``` 27 | 28 | ## Using ` 42 | ``` 43 | 44 | The `history.development.js` build is also available for non-production apps. 45 | 46 | In legacy browsers that do not yet support JavaScript modules, you can use one 47 | of our UMD (global) builds: 48 | 49 | ```html 50 | 51 | 52 | ``` 53 | 54 | You can find the library on `window.HistoryLibrary`. 55 | -------------------------------------------------------------------------------- /docs/navigation.md: -------------------------------------------------------------------------------- 1 | # Navigation 2 | 3 | `history` objects may be used to programmatically change the current location 4 | using the following methods: 5 | 6 | - [`history.push(to: To, state?: State)`](api-reference.md#history.push) 7 | - [`history.replace(to: To, state?: State)`](api-reference.md#history.replace) 8 | - [`history.go(delta: number)`](api-reference.md#history.go) 9 | - [`history.back()`](api-reference.md#history.back) 10 | - [`history.forward()`](api-reference.md#history.forward) 11 | 12 | An example: 13 | 14 | ```js 15 | // Push a new entry onto the history stack. 16 | history.push("/home"); 17 | 18 | // Push a new entry onto the history stack with a query string 19 | // and some state. Location state does not appear in the URL. 20 | history.push("/home?the=query", { some: "state" }); 21 | 22 | // If you prefer, use a location-like object to specify the URL. 23 | // This is equivalent to the example above. 24 | history.push( 25 | { 26 | pathname: "/home", 27 | search: "?the=query", 28 | }, 29 | { 30 | some: state, 31 | } 32 | ); 33 | 34 | // Go back to the previous history entry. The following 35 | // two lines are synonymous. 36 | history.go(-1); 37 | history.back(); 38 | ``` 39 | -------------------------------------------------------------------------------- /fixtures/block-library/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Navigation Blocking (library version)

5 | 6 |

7 | 8 |

9 |

10 | back & forward:
11 | 12 | 13 |

14 |

15 | pushState & replaceState:
16 | push / 17 | push /one 18 | push /two 19 | replace /three 20 |

21 |

22 | regular links:
23 | / 24 | /one 25 |

26 | 27 | 28 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /fixtures/block-library/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const express = require('express'); 3 | 4 | let port = process.env.PORT || 5000; 5 | let app = express(); 6 | 7 | app.use(express.static(__dirname)); 8 | 9 | app.get('*', (req, res) => { 10 | res.sendFile(__dirname + '/index.html'); 11 | }); 12 | 13 | app.listen(port, () => { 14 | console.log(`Server listening on http://localhost:${port}`); 15 | }); 16 | -------------------------------------------------------------------------------- /fixtures/block-vanilla/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

Navigation Blocking (vanilla JS version)

5 | 6 |

7 | 8 |

9 |

10 | back & forward:
11 | 12 | 13 |

14 |

15 | pushState & replaceState:
16 | push / 17 | push /one 18 | push /two 19 | replace /three 20 |

21 |

22 | regular links:
23 | / 24 | /one 25 |

26 | 27 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /fixtures/block-vanilla/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const express = require('express'); 3 | 4 | let port = process.env.PORT || 5000; 5 | let app = express(); 6 | 7 | app.use(express.static(__dirname)); 8 | 9 | app.get('*', (req, res) => { 10 | res.sendFile(__dirname + '/index.html'); 11 | }); 12 | 13 | app.listen(port, () => { 14 | console.log(`Server listening on http://localhost:${port}`); 15 | }); 16 | -------------------------------------------------------------------------------- /fixtures/hash-click.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | 18 | home 19 | #one 20 | #one w pushState 23 | #two 24 | #two w pushState 27 | 28 | 29 | -------------------------------------------------------------------------------- /fixtures/hash-history-length.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | home 10 | #one 11 | #two 12 | 13 | 14 | -------------------------------------------------------------------------------- /fixtures/unpkg-test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "node ./scripts/build.js", 5 | "clean": "git clean -fdX .", 6 | "lint": "eslint .", 7 | "format": "prettier --ignore-path .eslintignore --write .", 8 | "publish": "node ./scripts/publish.js", 9 | "size": "filesize", 10 | "test": "node ./scripts/test.js", 11 | "version": "node ./scripts/version.js", 12 | "watch": "node ./scripts/watch.js" 13 | }, 14 | "dependencies": { 15 | "@ampproject/filesize": "^4.3.0", 16 | "@ampproject/rollup-plugin-closure-compiler": "0.27.0", 17 | "@babel/core": "^7.15.0", 18 | "@babel/plugin-transform-runtime": "^7.15.0", 19 | "@babel/preset-env": "^7.15.0", 20 | "@babel/preset-modules": "0.1.4", 21 | "@babel/runtime": "^7.15.3", 22 | "@rollup/plugin-babel": "^5.3.0", 23 | "@rollup/plugin-replace": "^3.0.0", 24 | "@typescript-eslint/eslint-plugin": "^4.29.1", 25 | "@typescript-eslint/parser": "^4.29.1", 26 | "babel-core": "^7.0.0-bridge.0", 27 | "babel-eslint": "^10.1.0", 28 | "babel-loader": "^8.2.2", 29 | "babel-plugin-dev-expression": "0.2.2", 30 | "chalk": "^4.1.2", 31 | "eslint": "^7.32.0", 32 | "eslint-config-react-app": "^6.0.0", 33 | "eslint-plugin-flowtype": "^5.9.0", 34 | "eslint-plugin-import": "^2.24.0", 35 | "eslint-plugin-jsx-a11y": "^6.4.1", 36 | "eslint-plugin-react": "^7.24.0", 37 | "eslint-plugin-react-hooks": "^4.2.0", 38 | "expect": "^21.0.0", 39 | "express": "^4.17.1", 40 | "jest-mock": "^21.0.6", 41 | "jsonfile": "^6.1.0", 42 | "karma": "^6.3.4", 43 | "karma-browserstack-launcher": "^1.6.0", 44 | "karma-chrome-launcher": "^3.1.0", 45 | "karma-firefox-launcher": "^2.1.1", 46 | "karma-mocha": "^2.0.1", 47 | "karma-mocha-reporter": "^2.2.5", 48 | "karma-sourcemap-loader": "0.3.8", 49 | "karma-webpack": "^3.0.5", 50 | "mocha": "^5.2.0", 51 | "prettier": "^2.5.1", 52 | "prompt-confirm": "^2.0.4", 53 | "rollup": "^2.56.2", 54 | "rollup-plugin-copy": "^3.4.0", 55 | "rollup-plugin-prettier": "^2.1.0", 56 | "rollup-plugin-terser": "^7.0.2", 57 | "rollup-plugin-typescript2": "0.30.0", 58 | "semver": "^7.3.5", 59 | "typescript": "^4.3.5", 60 | "webpack": "^3.12.0" 61 | }, 62 | "workspaces": { 63 | "packages": [ 64 | "packages/history" 65 | ] 66 | }, 67 | "filesize": { 68 | "build/history/history.production.min.js": { 69 | "none": "5.06 kB" 70 | }, 71 | "build/history/umd/history.production.min.js": { 72 | "none": "6.03 kB" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /packages/history/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": false 6 | }, 7 | "globals": { 8 | "__DEV__": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/history/__tests__/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "rules": { 6 | "import/no-unresolved": [2, { "ignore": ["history"] }] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/BackButtonTransitionHook.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let hookWasCalled = false; 7 | let unblock; 8 | 9 | let steps = [ 10 | ({ location }) => { 11 | expect(location).toMatchObject({ 12 | pathname: "/", 13 | }); 14 | 15 | history.push("/home"); 16 | }, 17 | ({ action, location }) => { 18 | expect(action).toBe("PUSH"); 19 | expect(location).toMatchObject({ 20 | pathname: "/home", 21 | }); 22 | 23 | unblock = history.block(() => { 24 | hookWasCalled = true; 25 | }); 26 | 27 | window.history.go(-1); 28 | }, 29 | ({ action, location }) => { 30 | expect(action).toBe("POP"); 31 | expect(location).toMatchObject({ 32 | pathname: "/", 33 | }); 34 | 35 | expect(hookWasCalled).toBe(true); 36 | 37 | unblock(); 38 | }, 39 | ]; 40 | 41 | execSteps(steps, history, done); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/BlockEverything.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | let unblock = history.block(); 13 | 14 | history.push("/home"); 15 | 16 | expect(history.location).toMatchObject({ 17 | pathname: "/", 18 | }); 19 | 20 | unblock(); 21 | }, 22 | ]; 23 | 24 | execSteps(steps, history, done); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/BlockPopWithoutListening.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | export default (history, done) => { 4 | expect(history.location).toMatchObject({ 5 | pathname: "/", 6 | }); 7 | 8 | history.push("/home"); 9 | 10 | let transitionHookWasCalled = false; 11 | let unblock = history.block(() => { 12 | transitionHookWasCalled = true; 13 | }); 14 | 15 | // These timeouts are a hack to allow for the time it takes 16 | // for histories to reflect the change in the URL. Normally 17 | // we could just listen and avoid the waiting time. But this 18 | // test is designed to test what happens when we don't listen(), 19 | // so that's not an option here. 20 | 21 | // Allow some time for history to detect the PUSH. 22 | setTimeout(() => { 23 | history.back(); 24 | 25 | // Allow some time for history to detect the POP. 26 | setTimeout(() => { 27 | expect(transitionHookWasCalled).toBe(true); 28 | unblock(); 29 | 30 | done(); 31 | }, 100); 32 | }, 10); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/EncodedReservedCharacters.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | () => { 8 | // encoded string 9 | let pathname = "/view/%23abc"; 10 | history.replace(pathname); 11 | }, 12 | ({ location }) => { 13 | expect(location).toMatchObject({ 14 | pathname: "/view/%23abc", 15 | }); 16 | // encoded object 17 | let pathname = "/view/%23abc"; 18 | history.replace({ pathname }); 19 | }, 20 | ({ location }) => { 21 | expect(location).toMatchObject({ 22 | pathname: "/view/%23abc", 23 | }); 24 | // unencoded string 25 | let pathname = "/view/#abc"; 26 | history.replace(pathname); 27 | }, 28 | ({ location }) => { 29 | expect(location).toMatchObject({ 30 | pathname: "/view/", 31 | hash: "#abc", 32 | }); 33 | }, 34 | ]; 35 | 36 | execSteps(steps, history, done); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/GoBack.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toEqual("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | }); 19 | 20 | history.back(); 21 | }, 22 | ({ action, location }) => { 23 | expect(action).toEqual("POP"); 24 | expect(location).toMatchObject({ 25 | pathname: "/", 26 | }); 27 | }, 28 | ]; 29 | 30 | execSteps(steps, history, done); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/GoForward.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toEqual("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | }); 19 | 20 | history.back(); 21 | }, 22 | ({ action, location }) => { 23 | expect(action).toEqual("POP"); 24 | expect(location).toMatchObject({ 25 | pathname: "/", 26 | }); 27 | 28 | history.forward(); 29 | }, 30 | ({ action, location }) => { 31 | expect(action).toEqual("POP"); 32 | expect(location).toMatchObject({ 33 | pathname: "/home", 34 | }); 35 | }, 36 | ]; 37 | 38 | execSteps(steps, history, done); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/InitialLocationDefaultKey.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location.key).toBe("default"); 9 | }, 10 | ]; 11 | 12 | execSteps(steps, history, done); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/InitialLocationHasKey.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location.key).toBeTruthy(); 9 | }, 10 | ]; 11 | 12 | execSteps(steps, history, done); 13 | }; 14 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/Listen.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import mock from "jest-mock"; 3 | 4 | export default (history, done) => { 5 | let spy = mock.fn(); 6 | let unlisten = history.listen(spy); 7 | 8 | expect(spy).not.toHaveBeenCalled(); 9 | 10 | unlisten(); 11 | done(); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushMissingPathname.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home?the=query#the-hash"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | }); 21 | 22 | history.push("?another=query#another-hash"); 23 | }, 24 | ({ action, location }) => { 25 | expect(action).toBe("PUSH"); 26 | expect(location).toMatchObject({ 27 | pathname: "/home", 28 | search: "?another=query", 29 | hash: "#another-hash", 30 | }); 31 | }, 32 | ]; 33 | 34 | execSteps(steps, history, done); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushNewLocation.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home?the=query#the-hash"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | state: null, 21 | key: expect.any(String), 22 | }); 23 | }, 24 | ]; 25 | 26 | execSteps(steps, history, done); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushRelativePathname.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/the/path?the=query#the-hash"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/the/path", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | }); 21 | 22 | history.push("../other/path?another=query#another-hash"); 23 | }, 24 | ({ action, location }) => { 25 | expect(action).toBe("PUSH"); 26 | expect(location).toMatchObject({ 27 | pathname: "/other/path", 28 | search: "?another=query", 29 | hash: "#another-hash", 30 | }); 31 | }, 32 | ]; 33 | 34 | execSteps(steps, history, done); 35 | }; 36 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushRelativePathnameWarning.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps, spyOn } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/the/path?the=query#the-hash"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/the/path", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | }); 21 | 22 | let { spy, destroy } = spyOn(console, "warn"); 23 | 24 | history.push("../other/path?another=query#another-hash"); 25 | 26 | expect(spy).toHaveBeenCalledWith( 27 | expect.stringContaining("relative pathnames are not supported") 28 | ); 29 | 30 | destroy(); 31 | }, 32 | ({ location }) => { 33 | expect(location).toMatchObject({ 34 | pathname: "../other/path", 35 | search: "?another=query", 36 | hash: "#another-hash", 37 | }); 38 | }, 39 | ]; 40 | 41 | execSteps(steps, history, done); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushSamePath.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | }); 19 | 20 | history.push("/home"); 21 | }, 22 | ({ action, location }) => { 23 | expect(action).toBe("PUSH"); 24 | expect(location).toMatchObject({ 25 | pathname: "/home", 26 | }); 27 | 28 | history.back(); 29 | }, 30 | ({ action, location }) => { 31 | expect(action).toBe("POP"); 32 | expect(location).toMatchObject({ 33 | pathname: "/home", 34 | }); 35 | }, 36 | ]; 37 | 38 | execSteps(steps, history, done); 39 | }; 40 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/PushState.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.push("/home?the=query#the-hash", { the: "state" }); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("PUSH"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | state: { the: "state" }, 21 | }); 22 | }, 23 | ]; 24 | 25 | execSteps(steps, history, done); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/ReplaceNewLocation.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.replace("/home?the=query#the-hash"); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("REPLACE"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | state: null, 21 | key: expect.any(String), 22 | }); 23 | 24 | history.replace("/"); 25 | }, 26 | ({ action, location }) => { 27 | expect(action).toBe("REPLACE"); 28 | expect(location).toMatchObject({ 29 | pathname: "/", 30 | search: "", 31 | state: null, 32 | key: expect.any(String), 33 | }); 34 | }, 35 | ]; 36 | 37 | execSteps(steps, history, done); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/ReplaceSamePath.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let prevLocation; 7 | 8 | let steps = [ 9 | ({ location }) => { 10 | expect(location).toMatchObject({ 11 | pathname: "/", 12 | }); 13 | 14 | history.replace("/home"); 15 | }, 16 | ({ action, location }) => { 17 | expect(action).toBe("REPLACE"); 18 | expect(location).toMatchObject({ 19 | pathname: "/home", 20 | }); 21 | 22 | prevLocation = location; 23 | 24 | history.replace("/home"); 25 | }, 26 | ({ action, location }) => { 27 | expect(action).toBe("REPLACE"); 28 | expect(location).toMatchObject({ 29 | pathname: "/home", 30 | }); 31 | 32 | expect(location).not.toBe(prevLocation); 33 | }, 34 | ]; 35 | 36 | execSteps(steps, history, done); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/ReplaceState.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | 3 | import { execSteps } from "./utils.js"; 4 | 5 | export default (history, done) => { 6 | let steps = [ 7 | ({ location }) => { 8 | expect(location).toMatchObject({ 9 | pathname: "/", 10 | }); 11 | 12 | history.replace("/home?the=query#the-hash", { the: "state" }); 13 | }, 14 | ({ action, location }) => { 15 | expect(action).toBe("REPLACE"); 16 | expect(location).toMatchObject({ 17 | pathname: "/home", 18 | search: "?the=query", 19 | hash: "#the-hash", 20 | state: { the: "state" }, 21 | }); 22 | }, 23 | ]; 24 | 25 | execSteps(steps, history, done); 26 | }; 27 | -------------------------------------------------------------------------------- /packages/history/__tests__/TestSequences/utils.js: -------------------------------------------------------------------------------- 1 | import mock from "jest-mock"; 2 | 3 | export function spyOn(object, method) { 4 | let original = object[method]; 5 | let spy = mock.fn(); 6 | 7 | object[method] = spy; 8 | 9 | return { 10 | spy, 11 | destroy() { 12 | object[method] = original; 13 | }, 14 | }; 15 | } 16 | 17 | export function execSteps(steps, history, done) { 18 | let index = 0, 19 | unlisten, 20 | cleanedUp = false; 21 | 22 | function cleanup(...args) { 23 | if (!cleanedUp) { 24 | cleanedUp = true; 25 | unlisten(); 26 | done(...args); 27 | } 28 | } 29 | 30 | function execNextStep(...args) { 31 | try { 32 | let nextStep = steps[index++]; 33 | if (!nextStep) throw new Error("Test is missing step " + index); 34 | 35 | nextStep(...args); 36 | 37 | if (index === steps.length) cleanup(); 38 | } catch (error) { 39 | cleanup(error); 40 | } 41 | } 42 | 43 | if (steps.length) { 44 | unlisten = history.listen(execNextStep); 45 | 46 | execNextStep({ 47 | action: history.action, 48 | location: history.location, 49 | }); 50 | } else { 51 | done(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/history/__tests__/browser-test.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { createBrowserHistory } from "history"; 3 | 4 | import InitialLocationDefaultKey from "./TestSequences/InitialLocationDefaultKey.js"; 5 | import Listen from "./TestSequences/Listen.js"; 6 | import PushNewLocation from "./TestSequences/PushNewLocation.js"; 7 | import PushSamePath from "./TestSequences/PushSamePath.js"; 8 | import PushState from "./TestSequences/PushState.js"; 9 | import PushMissingPathname from "./TestSequences/PushMissingPathname.js"; 10 | import PushRelativePathname from "./TestSequences/PushRelativePathname.js"; 11 | import ReplaceNewLocation from "./TestSequences/ReplaceNewLocation.js"; 12 | import ReplaceSamePath from "./TestSequences/ReplaceSamePath.js"; 13 | import ReplaceState from "./TestSequences/ReplaceState.js"; 14 | import EncodedReservedCharacters from "./TestSequences/EncodedReservedCharacters.js"; 15 | import GoBack from "./TestSequences/GoBack.js"; 16 | import GoForward from "./TestSequences/GoForward.js"; 17 | import BlockEverything from "./TestSequences/BlockEverything.js"; 18 | import BlockPopWithoutListening from "./TestSequences/BlockPopWithoutListening.js"; 19 | 20 | describe("a browser history", () => { 21 | let history; 22 | beforeEach(() => { 23 | window.history.replaceState(null, null, "/"); 24 | history = createBrowserHistory(); 25 | }); 26 | 27 | it("knows how to create hrefs from location objects", () => { 28 | const href = history.createHref({ 29 | pathname: "/the/path", 30 | search: "?the=query", 31 | hash: "#the-hash", 32 | }); 33 | 34 | expect(href).toEqual("/the/path?the=query#the-hash"); 35 | }); 36 | 37 | it("knows how to create hrefs from strings", () => { 38 | const href = history.createHref("/the/path?the=query#the-hash"); 39 | expect(href).toEqual("/the/path?the=query#the-hash"); 40 | }); 41 | 42 | it("does not encode the generated path", () => { 43 | const encodedHref = history.createHref({ 44 | pathname: "/%23abc", 45 | }); 46 | expect(encodedHref).toEqual("/%23abc"); 47 | 48 | const unencodedHref = history.createHref({ 49 | pathname: "/#abc", 50 | }); 51 | expect(unencodedHref).toEqual("/#abc"); 52 | }); 53 | 54 | describe("listen", () => { 55 | it("does not immediately call listeners", (done) => { 56 | Listen(history, done); 57 | }); 58 | }); 59 | 60 | describe("the initial location", () => { 61 | it('has the "default" key', (done) => { 62 | InitialLocationDefaultKey(history, done); 63 | }); 64 | }); 65 | 66 | describe("push a new path", () => { 67 | it("calls change listeners with the new location", (done) => { 68 | PushNewLocation(history, done); 69 | }); 70 | }); 71 | 72 | describe("push the same path", () => { 73 | it("calls change listeners with the new location", (done) => { 74 | PushSamePath(history, done); 75 | }); 76 | }); 77 | 78 | describe("push state", () => { 79 | it("calls change listeners with the new location", (done) => { 80 | PushState(history, done); 81 | }); 82 | }); 83 | 84 | describe("push with no pathname", () => { 85 | it("reuses the current location pathname", (done) => { 86 | PushMissingPathname(history, done); 87 | }); 88 | }); 89 | 90 | describe("push with a relative pathname", () => { 91 | it("normalizes the pathname relative to the current location", (done) => { 92 | PushRelativePathname(history, done); 93 | }); 94 | }); 95 | 96 | describe("replace a new path", () => { 97 | it("calls change listeners with the new location", (done) => { 98 | ReplaceNewLocation(history, done); 99 | }); 100 | }); 101 | 102 | describe("replace the same path", () => { 103 | it("calls change listeners with the new location", (done) => { 104 | ReplaceSamePath(history, done); 105 | }); 106 | }); 107 | 108 | describe("replace state", () => { 109 | it("calls change listeners with the new location", (done) => { 110 | ReplaceState(history, done); 111 | }); 112 | }); 113 | 114 | describe("location created with encoded/unencoded reserved characters", () => { 115 | it("produces different location objects", (done) => { 116 | EncodedReservedCharacters(history, done); 117 | }); 118 | }); 119 | 120 | describe("back", () => { 121 | it("calls change listeners with the previous location", (done) => { 122 | GoBack(history, done); 123 | }); 124 | }); 125 | 126 | describe("forward", () => { 127 | it("calls change listeners with the next location", (done) => { 128 | GoForward(history, done); 129 | }); 130 | }); 131 | 132 | describe("block", () => { 133 | it("blocks all transitions", (done) => { 134 | BlockEverything(history, done); 135 | }); 136 | }); 137 | 138 | describe("block a POP without listening", () => { 139 | it("receives the next ({ action, location })", (done) => { 140 | BlockPopWithoutListening(history, done); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /packages/history/__tests__/create-path-test.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { createPath } from "history"; 3 | 4 | describe("createPath", () => { 5 | describe("given only a pathname", () => { 6 | it("returns the pathname unchanged", () => { 7 | let path = createPath({ pathname: "https://google.com" }); 8 | expect(path).toBe("https://google.com"); 9 | }); 10 | }); 11 | 12 | describe("given a pathname and a search param", () => { 13 | it("returns the constructed pathname", () => { 14 | let path = createPath({ 15 | pathname: "https://google.com", 16 | search: "?something=cool", 17 | }); 18 | expect(path).toBe("https://google.com?something=cool"); 19 | }); 20 | }); 21 | 22 | describe("given a pathname and a search param without ?", () => { 23 | it("returns the constructed pathname", () => { 24 | let path = createPath({ 25 | pathname: "https://google.com", 26 | search: "something=cool", 27 | }); 28 | expect(path).toBe("https://google.com?something=cool"); 29 | }); 30 | }); 31 | 32 | describe("given a pathname and a hash param", () => { 33 | it("returns the constructed pathname", () => { 34 | let path = createPath({ 35 | pathname: "https://google.com", 36 | hash: "#section-1", 37 | }); 38 | expect(path).toBe("https://google.com#section-1"); 39 | }); 40 | }); 41 | 42 | describe("given a pathname and a hash param without #", () => { 43 | it("returns the constructed pathname", () => { 44 | let path = createPath({ 45 | pathname: "https://google.com", 46 | hash: "section-1", 47 | }); 48 | expect(path).toBe("https://google.com#section-1"); 49 | }); 50 | }); 51 | 52 | describe("given a full location object", () => { 53 | it("returns the constructed pathname", () => { 54 | let path = createPath({ 55 | pathname: "https://google.com", 56 | search: "something=cool", 57 | hash: "#section-1", 58 | }); 59 | expect(path).toBe("https://google.com?something=cool#section-1"); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /packages/history/__tests__/hash-base-test.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { createHashHistory } from "history"; 3 | 4 | describe("a hash history on a page with a tag", () => { 5 | let history, base; 6 | beforeEach(() => { 7 | if (window.location.hash !== "#/") { 8 | window.location.hash = "/"; 9 | } 10 | 11 | base = document.createElement("base"); 12 | base.setAttribute("href", "/prefix"); 13 | 14 | document.head.appendChild(base); 15 | 16 | history = createHashHistory(); 17 | }); 18 | 19 | afterEach(() => { 20 | document.head.removeChild(base); 21 | }); 22 | 23 | it("knows how to create hrefs", () => { 24 | const hashIndex = window.location.href.indexOf("#"); 25 | const upToHash = 26 | hashIndex === -1 27 | ? window.location.href 28 | : window.location.href.slice(0, hashIndex); 29 | 30 | const href = history.createHref({ 31 | pathname: "/the/path", 32 | search: "?the=query", 33 | hash: "#the-hash", 34 | }); 35 | 36 | expect(href).toEqual(upToHash + "#/the/path?the=query#the-hash"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/history/__tests__/hash-test.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { createHashHistory } from "history"; 3 | 4 | import Listen from "./TestSequences/Listen.js"; 5 | import InitialLocationDefaultKey from "./TestSequences/InitialLocationDefaultKey.js"; 6 | import PushNewLocation from "./TestSequences/PushNewLocation.js"; 7 | import PushSamePath from "./TestSequences/PushSamePath.js"; 8 | import PushState from "./TestSequences/PushState.js"; 9 | import PushMissingPathname from "./TestSequences/PushMissingPathname.js"; 10 | import PushRelativePathnameWarning from "./TestSequences/PushRelativePathnameWarning.js"; 11 | import ReplaceNewLocation from "./TestSequences/ReplaceNewLocation.js"; 12 | import ReplaceSamePath from "./TestSequences/ReplaceSamePath.js"; 13 | import ReplaceState from "./TestSequences/ReplaceState.js"; 14 | import EncodedReservedCharacters from "./TestSequences/EncodedReservedCharacters.js"; 15 | import GoBack from "./TestSequences/GoBack.js"; 16 | import GoForward from "./TestSequences/GoForward.js"; 17 | import BlockEverything from "./TestSequences/BlockEverything.js"; 18 | import BlockPopWithoutListening from "./TestSequences/BlockPopWithoutListening.js"; 19 | 20 | // TODO: Do we still need this? 21 | // const canGoWithoutReload = window.navigator.userAgent.indexOf('Firefox') === -1; 22 | // const describeGo = canGoWithoutReload ? describe : describe.skip; 23 | 24 | describe("a hash history", () => { 25 | let history; 26 | beforeEach(() => { 27 | window.history.replaceState(null, null, "#/"); 28 | history = createHashHistory(); 29 | }); 30 | 31 | it("knows how to create hrefs from location objects", () => { 32 | const href = history.createHref({ 33 | pathname: "/the/path", 34 | search: "?the=query", 35 | hash: "#the-hash", 36 | }); 37 | 38 | expect(href).toEqual("#/the/path?the=query#the-hash"); 39 | }); 40 | 41 | it("knows how to create hrefs from strings", () => { 42 | const href = history.createHref("/the/path?the=query#the-hash"); 43 | expect(href).toEqual("#/the/path?the=query#the-hash"); 44 | }); 45 | 46 | it("does not encode the generated path", () => { 47 | const encodedHref = history.createHref({ 48 | pathname: "/%23abc", 49 | }); 50 | expect(encodedHref).toEqual("#/%23abc"); 51 | 52 | const unencodedHref = history.createHref({ 53 | pathname: "/#abc", 54 | }); 55 | expect(unencodedHref).toEqual("#/#abc"); 56 | }); 57 | 58 | describe("listen", () => { 59 | it("does not immediately call listeners", (done) => { 60 | Listen(history, done); 61 | }); 62 | }); 63 | 64 | describe("the initial location", () => { 65 | it('has the "default" key', (done) => { 66 | InitialLocationDefaultKey(history, done); 67 | }); 68 | }); 69 | 70 | describe("push a new path", () => { 71 | it("calls change listeners with the new location", (done) => { 72 | PushNewLocation(history, done); 73 | }); 74 | }); 75 | 76 | describe("push the same path", () => { 77 | it("calls change listeners with the new location", (done) => { 78 | PushSamePath(history, done); 79 | }); 80 | }); 81 | 82 | describe("push state", () => { 83 | it("calls change listeners with the new location", (done) => { 84 | PushState(history, done); 85 | }); 86 | }); 87 | 88 | describe("push with no pathname", () => { 89 | it("reuses the current location pathname", (done) => { 90 | PushMissingPathname(history, done); 91 | }); 92 | }); 93 | 94 | describe("push with a relative pathname", () => { 95 | it("issues a warning", (done) => { 96 | PushRelativePathnameWarning(history, done); 97 | }); 98 | }); 99 | 100 | describe("replace a new path", () => { 101 | it("calls change listeners with the new location", (done) => { 102 | ReplaceNewLocation(history, done); 103 | }); 104 | }); 105 | 106 | describe("replace the same path", () => { 107 | it("calls change listeners with the new location", (done) => { 108 | ReplaceSamePath(history, done); 109 | }); 110 | }); 111 | 112 | describe("replace state", () => { 113 | it("calls change listeners with the new location", (done) => { 114 | ReplaceState(history, done); 115 | }); 116 | }); 117 | 118 | describe("location created with encoded/unencoded reserved characters", () => { 119 | it("produces different location objects", (done) => { 120 | EncodedReservedCharacters(history, done); 121 | }); 122 | }); 123 | 124 | describe("back", () => { 125 | it("calls change listeners with the previous location", (done) => { 126 | GoBack(history, done); 127 | }); 128 | }); 129 | 130 | describe("forward", () => { 131 | it("calls change listeners with the next location", (done) => { 132 | GoForward(history, done); 133 | }); 134 | }); 135 | 136 | describe("block", () => { 137 | it("blocks all transitions", (done) => { 138 | BlockEverything(history, done); 139 | }); 140 | }); 141 | 142 | describe("block a POP without listening", () => { 143 | it("receives the next location and action as arguments", (done) => { 144 | BlockPopWithoutListening(history, done); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /packages/history/__tests__/memory-test.js: -------------------------------------------------------------------------------- 1 | import expect from "expect"; 2 | import { createMemoryHistory } from "history"; 3 | 4 | import Listen from "./TestSequences/Listen.js"; 5 | import InitialLocationHasKey from "./TestSequences/InitialLocationHasKey.js"; 6 | import PushNewLocation from "./TestSequences/PushNewLocation.js"; 7 | import PushSamePath from "./TestSequences/PushSamePath.js"; 8 | import PushState from "./TestSequences/PushState.js"; 9 | import PushMissingPathname from "./TestSequences/PushMissingPathname.js"; 10 | import PushRelativePathnameWarning from "./TestSequences/PushRelativePathnameWarning.js"; 11 | import ReplaceNewLocation from "./TestSequences/ReplaceNewLocation.js"; 12 | import ReplaceSamePath from "./TestSequences/ReplaceSamePath.js"; 13 | import ReplaceState from "./TestSequences/ReplaceState.js"; 14 | import EncodedReservedCharacters from "./TestSequences/EncodedReservedCharacters.js"; 15 | import GoBack from "./TestSequences/GoBack.js"; 16 | import GoForward from "./TestSequences/GoForward.js"; 17 | import BlockEverything from "./TestSequences/BlockEverything.js"; 18 | import BlockPopWithoutListening from "./TestSequences/BlockPopWithoutListening.js"; 19 | 20 | describe("a memory history", () => { 21 | let history; 22 | beforeEach(() => { 23 | history = createMemoryHistory(); 24 | }); 25 | 26 | it("has an index property", () => { 27 | expect(typeof history.index).toBe("number"); 28 | }); 29 | 30 | it("knows how to create hrefs", () => { 31 | const href = history.createHref({ 32 | pathname: "/the/path", 33 | search: "?the=query", 34 | hash: "#the-hash", 35 | }); 36 | 37 | expect(href).toEqual("/the/path?the=query#the-hash"); 38 | }); 39 | 40 | it("knows how to create hrefs from strings", () => { 41 | const href = history.createHref("/the/path?the=query#the-hash"); 42 | expect(href).toEqual("/the/path?the=query#the-hash"); 43 | }); 44 | 45 | it("does not encode the generated path", () => { 46 | const encodedHref = history.createHref({ 47 | pathname: "/%23abc", 48 | }); 49 | expect(encodedHref).toEqual("/%23abc"); 50 | 51 | const unencodedHref = history.createHref({ 52 | pathname: "/#abc", 53 | }); 54 | expect(unencodedHref).toEqual("/#abc"); 55 | }); 56 | 57 | describe("listen", () => { 58 | it("does not immediately call listeners", (done) => { 59 | Listen(history, done); 60 | }); 61 | }); 62 | 63 | describe("the initial location", () => { 64 | it("has a key", (done) => { 65 | InitialLocationHasKey(history, done); 66 | }); 67 | }); 68 | 69 | describe("push a new path", () => { 70 | it("calls change listeners with the new location", (done) => { 71 | PushNewLocation(history, done); 72 | }); 73 | }); 74 | 75 | describe("push the same path", () => { 76 | it("calls change listeners with the new location", (done) => { 77 | PushSamePath(history, done); 78 | }); 79 | }); 80 | 81 | describe("push state", () => { 82 | it("calls change listeners with the new location", (done) => { 83 | PushState(history, done); 84 | }); 85 | }); 86 | 87 | describe("push with no pathname", () => { 88 | it("reuses the current location pathname", (done) => { 89 | PushMissingPathname(history, done); 90 | }); 91 | }); 92 | 93 | describe("push with a relative pathname", () => { 94 | it("issues a warning", (done) => { 95 | PushRelativePathnameWarning(history, done); 96 | }); 97 | }); 98 | 99 | describe("replace a new path", () => { 100 | it("calls change listeners with the new location", (done) => { 101 | ReplaceNewLocation(history, done); 102 | }); 103 | }); 104 | 105 | describe("replace the same path", () => { 106 | it("calls change listeners with the new location", (done) => { 107 | ReplaceSamePath(history, done); 108 | }); 109 | }); 110 | 111 | describe("replace state", () => { 112 | it("calls change listeners with the new location", (done) => { 113 | ReplaceState(history, done); 114 | }); 115 | }); 116 | 117 | describe("location created with encoded/unencoded reserved characters", () => { 118 | it("produces different location objects", (done) => { 119 | EncodedReservedCharacters(history, done); 120 | }); 121 | }); 122 | 123 | describe("back", () => { 124 | it("calls change listeners with the previous location", (done) => { 125 | GoBack(history, done); 126 | }); 127 | }); 128 | 129 | describe("forward", () => { 130 | it("calls change listeners with the next location", (done) => { 131 | GoForward(history, done); 132 | }); 133 | }); 134 | 135 | describe("block", () => { 136 | it("blocks all transitions", (done) => { 137 | BlockEverything(history, done); 138 | }); 139 | }); 140 | 141 | describe("block a POP without listening", () => { 142 | it("receives the next location and action as arguments", (done) => { 143 | BlockPopWithoutListening(history, done); 144 | }); 145 | }); 146 | }); 147 | 148 | describe("a memory history with some initial entries", () => { 149 | it("clamps the initial index to a valid value", () => { 150 | let history = createMemoryHistory({ 151 | initialEntries: ["/one", "/two", "/three"], 152 | initialIndex: 3, // invalid 153 | }); 154 | 155 | expect(history.index).toBe(2); 156 | }); 157 | 158 | it("starts at the last entry by default", () => { 159 | let history = createMemoryHistory({ 160 | initialEntries: ["/one", "/two", "/three"], 161 | }); 162 | 163 | expect(history.index).toBe(2); 164 | expect(history.location).toMatchObject({ 165 | pathname: "/three", 166 | search: "", 167 | hash: "", 168 | state: null, 169 | key: expect.any(String), 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /packages/history/browser.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | 3 | /** 4 | * Create a default instance for the current document. 5 | */ 6 | export default createBrowserHistory(); 7 | -------------------------------------------------------------------------------- /packages/history/hash.ts: -------------------------------------------------------------------------------- 1 | import { createHashHistory } from "history"; 2 | 3 | /** 4 | * Create a default instance for the current document. 5 | */ 6 | export default createHashHistory(); 7 | -------------------------------------------------------------------------------- /packages/history/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Actions represent the type of change to a location value. 3 | * 4 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#action 5 | */ 6 | export enum Action { 7 | /** 8 | * A POP indicates a change to an arbitrary index in the history stack, such 9 | * as a back or forward navigation. It does not describe the direction of the 10 | * navigation, only that the current index changed. 11 | * 12 | * Note: This is the default action for newly created history objects. 13 | */ 14 | Pop = "POP", 15 | 16 | /** 17 | * A PUSH indicates a new entry being added to the history stack, such as when 18 | * a link is clicked and a new page loads. When this happens, all subsequent 19 | * entries in the stack are lost. 20 | */ 21 | Push = "PUSH", 22 | 23 | /** 24 | * A REPLACE indicates the entry at the current index in the history stack 25 | * being replaced by a new one. 26 | */ 27 | Replace = "REPLACE", 28 | } 29 | 30 | /** 31 | * A URL pathname, beginning with a /. 32 | * 33 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.pathname 34 | */ 35 | export type Pathname = string; 36 | 37 | /** 38 | * A URL search string, beginning with a ?. 39 | * 40 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.search 41 | */ 42 | export type Search = string; 43 | 44 | /** 45 | * A URL fragment identifier, beginning with a #. 46 | * 47 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.hash 48 | */ 49 | export type Hash = string; 50 | 51 | /** 52 | * An object that is used to associate some arbitrary data with a location, but 53 | * that does not appear in the URL path. 54 | * 55 | * @deprecated 56 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.state 57 | */ 58 | export type State = unknown; 59 | 60 | /** 61 | * A unique string associated with a location. May be used to safely store 62 | * and retrieve data in some other storage API, like `localStorage`. 63 | * 64 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.key 65 | */ 66 | export type Key = string; 67 | 68 | /** 69 | * The pathname, search, and hash values of a URL. 70 | */ 71 | export interface Path { 72 | /** 73 | * A URL pathname, beginning with a /. 74 | * 75 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.pathname 76 | */ 77 | pathname: Pathname; 78 | 79 | /** 80 | * A URL search string, beginning with a ?. 81 | * 82 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.search 83 | */ 84 | search: Search; 85 | 86 | /** 87 | * A URL fragment identifier, beginning with a #. 88 | * 89 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.hash 90 | */ 91 | hash: Hash; 92 | } 93 | 94 | /** 95 | * An entry in a history stack. A location contains information about the 96 | * URL path, as well as possibly some arbitrary state and a key. 97 | * 98 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location 99 | */ 100 | export interface Location extends Path { 101 | /** 102 | * A value of arbitrary data associated with this location. 103 | * 104 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.state 105 | */ 106 | state: unknown; 107 | 108 | /** 109 | * A unique string associated with this location. May be used to safely store 110 | * and retrieve data in some other storage API, like `localStorage`. 111 | * 112 | * Note: This value is always "default" on the initial location. 113 | * 114 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#location.key 115 | */ 116 | key: Key; 117 | } 118 | 119 | /** 120 | * A partial Path object that may be missing some properties. 121 | * 122 | * @deprecated 123 | */ 124 | export type PartialPath = Partial; 125 | 126 | /** 127 | * A partial Location object that may be missing some properties. 128 | * 129 | * @deprecated 130 | */ 131 | export type PartialLocation = Partial; 132 | 133 | /** 134 | * A change to the current location. 135 | */ 136 | export interface Update { 137 | /** 138 | * The action that triggered the change. 139 | */ 140 | action: Action; 141 | 142 | /** 143 | * The new location. 144 | */ 145 | location: Location; 146 | } 147 | 148 | /** 149 | * A function that receives notifications about location changes. 150 | */ 151 | export interface Listener { 152 | (update: Update): void; 153 | } 154 | 155 | /** 156 | * A change to the current location that was blocked. May be retried 157 | * after obtaining user confirmation. 158 | */ 159 | export interface Transition extends Update { 160 | /** 161 | * Retries the update to the current location. 162 | */ 163 | retry(): void; 164 | } 165 | 166 | /** 167 | * A function that receives transitions when navigation is blocked. 168 | */ 169 | export interface Blocker { 170 | (tx: Transition): void; 171 | } 172 | 173 | /** 174 | * Describes a location that is the destination of some navigation, either via 175 | * `history.push` or `history.replace`. May be either a URL or the pieces of a 176 | * URL path. 177 | */ 178 | export type To = string | Partial; 179 | 180 | /** 181 | * A history is an interface to the navigation stack. The history serves as the 182 | * source of truth for the current location, as well as provides a set of 183 | * methods that may be used to change it. 184 | * 185 | * It is similar to the DOM's `window.history` object, but with a smaller, more 186 | * focused API. 187 | */ 188 | export interface History { 189 | /** 190 | * The last action that modified the current location. This will always be 191 | * Action.Pop when a history instance is first created. This value is mutable. 192 | * 193 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.action 194 | */ 195 | readonly action: Action; 196 | 197 | /** 198 | * The current location. This value is mutable. 199 | * 200 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.location 201 | */ 202 | readonly location: Location; 203 | 204 | /** 205 | * Returns a valid href for the given `to` value that may be used as 206 | * the value of an attribute. 207 | * 208 | * @param to - The destination URL 209 | * 210 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.createHref 211 | */ 212 | createHref(to: To): string; 213 | 214 | /** 215 | * Pushes a new location onto the history stack, increasing its length by one. 216 | * If there were any entries in the stack after the current one, they are 217 | * lost. 218 | * 219 | * @param to - The new URL 220 | * @param state - Data to associate with the new location 221 | * 222 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.push 223 | */ 224 | push(to: To, state?: any): void; 225 | 226 | /** 227 | * Replaces the current location in the history stack with a new one. The 228 | * location that was replaced will no longer be available. 229 | * 230 | * @param to - The new URL 231 | * @param state - Data to associate with the new location 232 | * 233 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.replace 234 | */ 235 | replace(to: To, state?: any): void; 236 | 237 | /** 238 | * Navigates `n` entries backward/forward in the history stack relative to the 239 | * current index. For example, a "back" navigation would use go(-1). 240 | * 241 | * @param delta - The delta in the stack index 242 | * 243 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.go 244 | */ 245 | go(delta: number): void; 246 | 247 | /** 248 | * Navigates to the previous entry in the stack. Identical to go(-1). 249 | * 250 | * Warning: if the current location is the first location in the stack, this 251 | * will unload the current document. 252 | * 253 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.back 254 | */ 255 | back(): void; 256 | 257 | /** 258 | * Navigates to the next entry in the stack. Identical to go(1). 259 | * 260 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.forward 261 | */ 262 | forward(): void; 263 | 264 | /** 265 | * Sets up a listener that will be called whenever the current location 266 | * changes. 267 | * 268 | * @param listener - A function that will be called when the location changes 269 | * @returns unlisten - A function that may be used to stop listening 270 | * 271 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.listen 272 | */ 273 | listen(listener: Listener): () => void; 274 | 275 | /** 276 | * Prevents the current location from changing and sets up a listener that 277 | * will be called instead. 278 | * 279 | * @param blocker - A function that will be called when a transition is blocked 280 | * @returns unblock - A function that may be used to stop blocking 281 | * 282 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#history.block 283 | */ 284 | block(blocker: Blocker): () => void; 285 | } 286 | 287 | /** 288 | * A browser history stores the current location in regular URLs in a web 289 | * browser environment. This is the standard for most web apps and provides the 290 | * cleanest URLs the browser's address bar. 291 | * 292 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#browserhistory 293 | */ 294 | export interface BrowserHistory extends History {} 295 | 296 | /** 297 | * A hash history stores the current location in the fragment identifier portion 298 | * of the URL in a web browser environment. 299 | * 300 | * This is ideal for apps that do not control the server for some reason 301 | * (because the fragment identifier is never sent to the server), including some 302 | * shared hosting environments that do not provide fine-grained controls over 303 | * which pages are served at which URLs. 304 | * 305 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#hashhistory 306 | */ 307 | export interface HashHistory extends History {} 308 | 309 | /** 310 | * A memory history stores locations in memory. This is useful in stateful 311 | * environments where there is no web browser, such as node tests or React 312 | * Native. 313 | * 314 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#memoryhistory 315 | */ 316 | export interface MemoryHistory extends History { 317 | readonly index: number; 318 | } 319 | 320 | const readOnly: (obj: T) => Readonly = __DEV__ 321 | ? (obj) => Object.freeze(obj) 322 | : (obj) => obj; 323 | 324 | function warning(cond: any, message: string) { 325 | if (!cond) { 326 | // eslint-disable-next-line no-console 327 | if (typeof console !== "undefined") console.warn(message); 328 | 329 | try { 330 | // Welcome to debugging history! 331 | // 332 | // This error is thrown as a convenience so you can more easily 333 | // find the source for a warning that appears in the console by 334 | // enabling "pause on exceptions" in your JavaScript debugger. 335 | throw new Error(message); 336 | // eslint-disable-next-line no-empty 337 | } catch (e) {} 338 | } 339 | } 340 | 341 | //////////////////////////////////////////////////////////////////////////////// 342 | // BROWSER 343 | //////////////////////////////////////////////////////////////////////////////// 344 | 345 | type HistoryState = { 346 | usr: any; 347 | key?: string; 348 | idx: number; 349 | }; 350 | 351 | const BeforeUnloadEventType = "beforeunload"; 352 | const HashChangeEventType = "hashchange"; 353 | const PopStateEventType = "popstate"; 354 | 355 | export type BrowserHistoryOptions = { window?: Window }; 356 | 357 | /** 358 | * Browser history stores the location in regular URLs. This is the standard for 359 | * most web apps, but it requires some configuration on the server to ensure you 360 | * serve the same app at multiple URLs. 361 | * 362 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory 363 | */ 364 | export function createBrowserHistory( 365 | options: BrowserHistoryOptions = {} 366 | ): BrowserHistory { 367 | let { window = document.defaultView! } = options; 368 | let globalHistory = window.history; 369 | 370 | function getIndexAndLocation(): [number, Location] { 371 | let { pathname, search, hash } = window.location; 372 | let state = globalHistory.state || {}; 373 | return [ 374 | state.idx, 375 | readOnly({ 376 | pathname, 377 | search, 378 | hash, 379 | state: state.usr || null, 380 | key: state.key || "default", 381 | }), 382 | ]; 383 | } 384 | 385 | let blockedPopTx: Transition | null = null; 386 | function handlePop() { 387 | if (blockedPopTx) { 388 | blockers.call(blockedPopTx); 389 | blockedPopTx = null; 390 | } else { 391 | let nextAction = Action.Pop; 392 | let [nextIndex, nextLocation] = getIndexAndLocation(); 393 | 394 | if (blockers.length) { 395 | if (nextIndex != null) { 396 | let delta = index - nextIndex; 397 | if (delta) { 398 | // Revert the POP 399 | blockedPopTx = { 400 | action: nextAction, 401 | location: nextLocation, 402 | retry() { 403 | go(delta * -1); 404 | }, 405 | }; 406 | 407 | go(delta); 408 | } 409 | } else { 410 | // Trying to POP to a location with no index. We did not create 411 | // this location, so we can't effectively block the navigation. 412 | warning( 413 | false, 414 | // TODO: Write up a doc that explains our blocking strategy in 415 | // detail and link to it here so people can understand better what 416 | // is going on and how to avoid it. 417 | `You are trying to block a POP navigation to a location that was not ` + 418 | `created by the history library. The block will fail silently in ` + 419 | `production, but in general you should do all navigation with the ` + 420 | `history library (instead of using window.history.pushState directly) ` + 421 | `to avoid this situation.` 422 | ); 423 | } 424 | } else { 425 | applyTx(nextAction); 426 | } 427 | } 428 | } 429 | 430 | window.addEventListener(PopStateEventType, handlePop); 431 | 432 | let action = Action.Pop; 433 | let [index, location] = getIndexAndLocation(); 434 | let listeners = createEvents(); 435 | let blockers = createEvents(); 436 | 437 | if (index == null) { 438 | index = 0; 439 | globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); 440 | } 441 | 442 | function createHref(to: To) { 443 | return typeof to === "string" ? to : createPath(to); 444 | } 445 | 446 | // state defaults to `null` because `window.history.state` does 447 | function getNextLocation(to: To, state: any = null): Location { 448 | return readOnly({ 449 | pathname: location.pathname, 450 | hash: "", 451 | search: "", 452 | ...(typeof to === "string" ? parsePath(to) : to), 453 | state, 454 | key: createKey(), 455 | }); 456 | } 457 | 458 | function getHistoryStateAndUrl( 459 | nextLocation: Location, 460 | index: number 461 | ): [HistoryState, string] { 462 | return [ 463 | { 464 | usr: nextLocation.state, 465 | key: nextLocation.key, 466 | idx: index, 467 | }, 468 | createHref(nextLocation), 469 | ]; 470 | } 471 | 472 | function allowTx(action: Action, location: Location, retry: () => void) { 473 | return ( 474 | !blockers.length || (blockers.call({ action, location, retry }), false) 475 | ); 476 | } 477 | 478 | function applyTx(nextAction: Action) { 479 | action = nextAction; 480 | [index, location] = getIndexAndLocation(); 481 | listeners.call({ action, location }); 482 | } 483 | 484 | function push(to: To, state?: any) { 485 | let nextAction = Action.Push; 486 | let nextLocation = getNextLocation(to, state); 487 | function retry() { 488 | push(to, state); 489 | } 490 | 491 | if (allowTx(nextAction, nextLocation, retry)) { 492 | let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1); 493 | 494 | // TODO: Support forced reloading 495 | // try...catch because iOS limits us to 100 pushState calls :/ 496 | try { 497 | globalHistory.pushState(historyState, "", url); 498 | } catch (error) { 499 | // They are going to lose state here, but there is no real 500 | // way to warn them about it since the page will refresh... 501 | window.location.assign(url); 502 | } 503 | 504 | applyTx(nextAction); 505 | } 506 | } 507 | 508 | function replace(to: To, state?: any) { 509 | let nextAction = Action.Replace; 510 | let nextLocation = getNextLocation(to, state); 511 | function retry() { 512 | replace(to, state); 513 | } 514 | 515 | if (allowTx(nextAction, nextLocation, retry)) { 516 | let [historyState, url] = getHistoryStateAndUrl(nextLocation, index); 517 | 518 | // TODO: Support forced reloading 519 | globalHistory.replaceState(historyState, "", url); 520 | 521 | applyTx(nextAction); 522 | } 523 | } 524 | 525 | function go(delta: number) { 526 | globalHistory.go(delta); 527 | } 528 | 529 | let history: BrowserHistory = { 530 | get action() { 531 | return action; 532 | }, 533 | get location() { 534 | return location; 535 | }, 536 | createHref, 537 | push, 538 | replace, 539 | go, 540 | back() { 541 | go(-1); 542 | }, 543 | forward() { 544 | go(1); 545 | }, 546 | listen(listener) { 547 | return listeners.push(listener); 548 | }, 549 | block(blocker) { 550 | let unblock = blockers.push(blocker); 551 | 552 | if (blockers.length === 1) { 553 | window.addEventListener(BeforeUnloadEventType, promptBeforeUnload); 554 | } 555 | 556 | return function () { 557 | unblock(); 558 | 559 | // Remove the beforeunload listener so the document may 560 | // still be salvageable in the pagehide event. 561 | // See https://html.spec.whatwg.org/#unloading-documents 562 | if (!blockers.length) { 563 | window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload); 564 | } 565 | }; 566 | }, 567 | }; 568 | 569 | return history; 570 | } 571 | 572 | //////////////////////////////////////////////////////////////////////////////// 573 | // HASH 574 | //////////////////////////////////////////////////////////////////////////////// 575 | 576 | export type HashHistoryOptions = { window?: Window }; 577 | 578 | /** 579 | * Hash history stores the location in window.location.hash. This makes it ideal 580 | * for situations where you don't want to send the location to the server for 581 | * some reason, either because you do cannot configure it or the URL space is 582 | * reserved for something else. 583 | * 584 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createhashhistory 585 | */ 586 | export function createHashHistory( 587 | options: HashHistoryOptions = {} 588 | ): HashHistory { 589 | let { window = document.defaultView! } = options; 590 | let globalHistory = window.history; 591 | 592 | function getIndexAndLocation(): [number, Location] { 593 | let { 594 | pathname = "/", 595 | search = "", 596 | hash = "", 597 | } = parsePath(window.location.hash.substr(1)); 598 | let state = globalHistory.state || {}; 599 | return [ 600 | state.idx, 601 | readOnly({ 602 | pathname, 603 | search, 604 | hash, 605 | state: state.usr || null, 606 | key: state.key || "default", 607 | }), 608 | ]; 609 | } 610 | 611 | let blockedPopTx: Transition | null = null; 612 | function handlePop() { 613 | if (blockedPopTx) { 614 | blockers.call(blockedPopTx); 615 | blockedPopTx = null; 616 | } else { 617 | let nextAction = Action.Pop; 618 | let [nextIndex, nextLocation] = getIndexAndLocation(); 619 | 620 | if (blockers.length) { 621 | if (nextIndex != null) { 622 | let delta = index - nextIndex; 623 | if (delta) { 624 | // Revert the POP 625 | blockedPopTx = { 626 | action: nextAction, 627 | location: nextLocation, 628 | retry() { 629 | go(delta * -1); 630 | }, 631 | }; 632 | 633 | go(delta); 634 | } 635 | } else { 636 | // Trying to POP to a location with no index. We did not create 637 | // this location, so we can't effectively block the navigation. 638 | warning( 639 | false, 640 | // TODO: Write up a doc that explains our blocking strategy in 641 | // detail and link to it here so people can understand better 642 | // what is going on and how to avoid it. 643 | `You are trying to block a POP navigation to a location that was not ` + 644 | `created by the history library. The block will fail silently in ` + 645 | `production, but in general you should do all navigation with the ` + 646 | `history library (instead of using window.history.pushState directly) ` + 647 | `to avoid this situation.` 648 | ); 649 | } 650 | } else { 651 | applyTx(nextAction); 652 | } 653 | } 654 | } 655 | 656 | window.addEventListener(PopStateEventType, handlePop); 657 | 658 | // popstate does not fire on hashchange in IE 11 and old (trident) Edge 659 | // https://developer.mozilla.org/de/docs/Web/API/Window/popstate_event 660 | window.addEventListener(HashChangeEventType, () => { 661 | let [, nextLocation] = getIndexAndLocation(); 662 | 663 | // Ignore extraneous hashchange events. 664 | if (createPath(nextLocation) !== createPath(location)) { 665 | handlePop(); 666 | } 667 | }); 668 | 669 | let action = Action.Pop; 670 | let [index, location] = getIndexAndLocation(); 671 | let listeners = createEvents(); 672 | let blockers = createEvents(); 673 | 674 | if (index == null) { 675 | index = 0; 676 | globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); 677 | } 678 | 679 | function getBaseHref() { 680 | let base = document.querySelector("base"); 681 | let href = ""; 682 | 683 | if (base && base.getAttribute("href")) { 684 | let url = window.location.href; 685 | let hashIndex = url.indexOf("#"); 686 | href = hashIndex === -1 ? url : url.slice(0, hashIndex); 687 | } 688 | 689 | return href; 690 | } 691 | 692 | function createHref(to: To) { 693 | return getBaseHref() + "#" + (typeof to === "string" ? to : createPath(to)); 694 | } 695 | 696 | function getNextLocation(to: To, state: any = null): Location { 697 | return readOnly({ 698 | pathname: location.pathname, 699 | hash: "", 700 | search: "", 701 | ...(typeof to === "string" ? parsePath(to) : to), 702 | state, 703 | key: createKey(), 704 | }); 705 | } 706 | 707 | function getHistoryStateAndUrl( 708 | nextLocation: Location, 709 | index: number 710 | ): [HistoryState, string] { 711 | return [ 712 | { 713 | usr: nextLocation.state, 714 | key: nextLocation.key, 715 | idx: index, 716 | }, 717 | createHref(nextLocation), 718 | ]; 719 | } 720 | 721 | function allowTx(action: Action, location: Location, retry: () => void) { 722 | return ( 723 | !blockers.length || (blockers.call({ action, location, retry }), false) 724 | ); 725 | } 726 | 727 | function applyTx(nextAction: Action) { 728 | action = nextAction; 729 | [index, location] = getIndexAndLocation(); 730 | listeners.call({ action, location }); 731 | } 732 | 733 | function push(to: To, state?: any) { 734 | let nextAction = Action.Push; 735 | let nextLocation = getNextLocation(to, state); 736 | function retry() { 737 | push(to, state); 738 | } 739 | 740 | warning( 741 | nextLocation.pathname.charAt(0) === "/", 742 | `Relative pathnames are not supported in hash history.push(${JSON.stringify( 743 | to 744 | )})` 745 | ); 746 | 747 | if (allowTx(nextAction, nextLocation, retry)) { 748 | let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1); 749 | 750 | // TODO: Support forced reloading 751 | // try...catch because iOS limits us to 100 pushState calls :/ 752 | try { 753 | globalHistory.pushState(historyState, "", url); 754 | } catch (error) { 755 | // They are going to lose state here, but there is no real 756 | // way to warn them about it since the page will refresh... 757 | window.location.assign(url); 758 | } 759 | 760 | applyTx(nextAction); 761 | } 762 | } 763 | 764 | function replace(to: To, state?: any) { 765 | let nextAction = Action.Replace; 766 | let nextLocation = getNextLocation(to, state); 767 | function retry() { 768 | replace(to, state); 769 | } 770 | 771 | warning( 772 | nextLocation.pathname.charAt(0) === "/", 773 | `Relative pathnames are not supported in hash history.replace(${JSON.stringify( 774 | to 775 | )})` 776 | ); 777 | 778 | if (allowTx(nextAction, nextLocation, retry)) { 779 | let [historyState, url] = getHistoryStateAndUrl(nextLocation, index); 780 | 781 | // TODO: Support forced reloading 782 | globalHistory.replaceState(historyState, "", url); 783 | 784 | applyTx(nextAction); 785 | } 786 | } 787 | 788 | function go(delta: number) { 789 | globalHistory.go(delta); 790 | } 791 | 792 | let history: HashHistory = { 793 | get action() { 794 | return action; 795 | }, 796 | get location() { 797 | return location; 798 | }, 799 | createHref, 800 | push, 801 | replace, 802 | go, 803 | back() { 804 | go(-1); 805 | }, 806 | forward() { 807 | go(1); 808 | }, 809 | listen(listener) { 810 | return listeners.push(listener); 811 | }, 812 | block(blocker) { 813 | let unblock = blockers.push(blocker); 814 | 815 | if (blockers.length === 1) { 816 | window.addEventListener(BeforeUnloadEventType, promptBeforeUnload); 817 | } 818 | 819 | return function () { 820 | unblock(); 821 | 822 | // Remove the beforeunload listener so the document may 823 | // still be salvageable in the pagehide event. 824 | // See https://html.spec.whatwg.org/#unloading-documents 825 | if (!blockers.length) { 826 | window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload); 827 | } 828 | }; 829 | }, 830 | }; 831 | 832 | return history; 833 | } 834 | 835 | //////////////////////////////////////////////////////////////////////////////// 836 | // MEMORY 837 | //////////////////////////////////////////////////////////////////////////////// 838 | 839 | /** 840 | * A user-supplied object that describes a location. Used when providing 841 | * entries to `createMemoryHistory` via its `initialEntries` option. 842 | */ 843 | export type InitialEntry = string | Partial; 844 | 845 | export type MemoryHistoryOptions = { 846 | initialEntries?: InitialEntry[]; 847 | initialIndex?: number; 848 | }; 849 | 850 | /** 851 | * Memory history stores the current location in memory. It is designed for use 852 | * in stateful non-browser environments like tests and React Native. 853 | * 854 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#creatememoryhistory 855 | */ 856 | export function createMemoryHistory( 857 | options: MemoryHistoryOptions = {} 858 | ): MemoryHistory { 859 | let { initialEntries = ["/"], initialIndex } = options; 860 | let entries: Location[] = initialEntries.map((entry) => { 861 | let location = readOnly({ 862 | pathname: "/", 863 | search: "", 864 | hash: "", 865 | state: null, 866 | key: createKey(), 867 | ...(typeof entry === "string" ? parsePath(entry) : entry), 868 | }); 869 | 870 | warning( 871 | location.pathname.charAt(0) === "/", 872 | `Relative pathnames are not supported in createMemoryHistory({ initialEntries }) (invalid entry: ${JSON.stringify( 873 | entry 874 | )})` 875 | ); 876 | 877 | return location; 878 | }); 879 | let index = clamp( 880 | initialIndex == null ? entries.length - 1 : initialIndex, 881 | 0, 882 | entries.length - 1 883 | ); 884 | 885 | let action = Action.Pop; 886 | let location = entries[index]; 887 | let listeners = createEvents(); 888 | let blockers = createEvents(); 889 | 890 | function createHref(to: To) { 891 | return typeof to === "string" ? to : createPath(to); 892 | } 893 | 894 | function getNextLocation(to: To, state: any = null): Location { 895 | return readOnly({ 896 | pathname: location.pathname, 897 | search: "", 898 | hash: "", 899 | ...(typeof to === "string" ? parsePath(to) : to), 900 | state, 901 | key: createKey(), 902 | }); 903 | } 904 | 905 | function allowTx(action: Action, location: Location, retry: () => void) { 906 | return ( 907 | !blockers.length || (blockers.call({ action, location, retry }), false) 908 | ); 909 | } 910 | 911 | function applyTx(nextAction: Action, nextLocation: Location) { 912 | action = nextAction; 913 | location = nextLocation; 914 | listeners.call({ action, location }); 915 | } 916 | 917 | function push(to: To, state?: any) { 918 | let nextAction = Action.Push; 919 | let nextLocation = getNextLocation(to, state); 920 | function retry() { 921 | push(to, state); 922 | } 923 | 924 | warning( 925 | location.pathname.charAt(0) === "/", 926 | `Relative pathnames are not supported in memory history.push(${JSON.stringify( 927 | to 928 | )})` 929 | ); 930 | 931 | if (allowTx(nextAction, nextLocation, retry)) { 932 | index += 1; 933 | entries.splice(index, entries.length, nextLocation); 934 | applyTx(nextAction, nextLocation); 935 | } 936 | } 937 | 938 | function replace(to: To, state?: any) { 939 | let nextAction = Action.Replace; 940 | let nextLocation = getNextLocation(to, state); 941 | function retry() { 942 | replace(to, state); 943 | } 944 | 945 | warning( 946 | location.pathname.charAt(0) === "/", 947 | `Relative pathnames are not supported in memory history.replace(${JSON.stringify( 948 | to 949 | )})` 950 | ); 951 | 952 | if (allowTx(nextAction, nextLocation, retry)) { 953 | entries[index] = nextLocation; 954 | applyTx(nextAction, nextLocation); 955 | } 956 | } 957 | 958 | function go(delta: number) { 959 | let nextIndex = clamp(index + delta, 0, entries.length - 1); 960 | let nextAction = Action.Pop; 961 | let nextLocation = entries[nextIndex]; 962 | function retry() { 963 | go(delta); 964 | } 965 | 966 | if (allowTx(nextAction, nextLocation, retry)) { 967 | index = nextIndex; 968 | applyTx(nextAction, nextLocation); 969 | } 970 | } 971 | 972 | let history: MemoryHistory = { 973 | get index() { 974 | return index; 975 | }, 976 | get action() { 977 | return action; 978 | }, 979 | get location() { 980 | return location; 981 | }, 982 | createHref, 983 | push, 984 | replace, 985 | go, 986 | back() { 987 | go(-1); 988 | }, 989 | forward() { 990 | go(1); 991 | }, 992 | listen(listener) { 993 | return listeners.push(listener); 994 | }, 995 | block(blocker) { 996 | return blockers.push(blocker); 997 | }, 998 | }; 999 | 1000 | return history; 1001 | } 1002 | 1003 | //////////////////////////////////////////////////////////////////////////////// 1004 | // UTILS 1005 | //////////////////////////////////////////////////////////////////////////////// 1006 | 1007 | function clamp(n: number, lowerBound: number, upperBound: number) { 1008 | return Math.min(Math.max(n, lowerBound), upperBound); 1009 | } 1010 | 1011 | function promptBeforeUnload(event: BeforeUnloadEvent) { 1012 | // Cancel the event. 1013 | event.preventDefault(); 1014 | // Chrome (and legacy IE) requires returnValue to be set. 1015 | event.returnValue = ""; 1016 | } 1017 | 1018 | type Events = { 1019 | length: number; 1020 | push: (fn: F) => () => void; 1021 | call: (arg: any) => void; 1022 | }; 1023 | 1024 | function createEvents(): Events { 1025 | let handlers: F[] = []; 1026 | 1027 | return { 1028 | get length() { 1029 | return handlers.length; 1030 | }, 1031 | push(fn: F) { 1032 | handlers.push(fn); 1033 | return function () { 1034 | handlers = handlers.filter((handler) => handler !== fn); 1035 | }; 1036 | }, 1037 | call(arg) { 1038 | handlers.forEach((fn) => fn && fn(arg)); 1039 | }, 1040 | }; 1041 | } 1042 | 1043 | function createKey() { 1044 | return Math.random().toString(36).substr(2, 8); 1045 | } 1046 | 1047 | /** 1048 | * Creates a string URL path from the given pathname, search, and hash components. 1049 | * 1050 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createpath 1051 | */ 1052 | export function createPath({ 1053 | pathname = "/", 1054 | search = "", 1055 | hash = "", 1056 | }: Partial) { 1057 | if (search && search !== "?") 1058 | pathname += search.charAt(0) === "?" ? search : "?" + search; 1059 | if (hash && hash !== "#") 1060 | pathname += hash.charAt(0) === "#" ? hash : "#" + hash; 1061 | return pathname; 1062 | } 1063 | 1064 | /** 1065 | * Parses a string URL path into its separate pathname, search, and hash components. 1066 | * 1067 | * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#parsepath 1068 | */ 1069 | export function parsePath(path: string): Partial { 1070 | let parsedPath: Partial = {}; 1071 | 1072 | if (path) { 1073 | let hashIndex = path.indexOf("#"); 1074 | if (hashIndex >= 0) { 1075 | parsedPath.hash = path.substr(hashIndex); 1076 | path = path.substr(0, hashIndex); 1077 | } 1078 | 1079 | let searchIndex = path.indexOf("?"); 1080 | if (searchIndex >= 0) { 1081 | parsedPath.search = path.substr(searchIndex); 1082 | path = path.substr(0, searchIndex); 1083 | } 1084 | 1085 | if (path) { 1086 | parsedPath.pathname = path; 1087 | } 1088 | } 1089 | 1090 | return parsedPath; 1091 | } 1092 | -------------------------------------------------------------------------------- /packages/history/node-main.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./umd/history.production.min.js"); 5 | } else { 6 | module.exports = require("./umd/history.development.js"); 7 | } 8 | -------------------------------------------------------------------------------- /packages/history/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "history", 3 | "version": "5.3.0", 4 | "description": "Manage session history with JavaScript", 5 | "author": "Remix Software ", 6 | "repository": "remix-run/history", 7 | "license": "MIT", 8 | "main": "main.js", 9 | "module": "index.js", 10 | "types": "index.d.ts", 11 | "unpkg": "umd/history.production.min.js", 12 | "sideEffects": false, 13 | "dependencies": { 14 | "@babel/runtime": "^7.7.6" 15 | }, 16 | "keywords": [ 17 | "history", 18 | "location" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const execSync = require("child_process").execSync; 3 | 4 | let config = path.resolve(__dirname, "rollup/history.config.js"); 5 | 6 | execSync(`rollup -c ${config}`, { 7 | env: process.env, 8 | stdio: "inherit", 9 | }); 10 | -------------------------------------------------------------------------------- /scripts/karma.conf.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var webpack = require("webpack"); 3 | 4 | module.exports = function (config) { 5 | var customLaunchers = { 6 | BS_Chrome: { 7 | name: "Chrome", 8 | base: "BrowserStack", 9 | os: "Windows", 10 | os_version: "10", 11 | browser: "Chrome", 12 | browser_version: "73.0", 13 | }, 14 | // BS_ChromeAndroid: { 15 | // base: 'BrowserStack', 16 | // device: 'Samsung Galaxy S8', 17 | // os_version: '7.0', 18 | // real_mobile: true 19 | // }, 20 | BS_Firefox: { 21 | name: "Firefox", 22 | base: "BrowserStack", 23 | os: "Windows", 24 | os_version: "10", 25 | browser: "Firefox", 26 | browser_version: "67.0", 27 | }, 28 | BS_Edge: { 29 | name: "Edge", 30 | base: "BrowserStack", 31 | os: "Windows", 32 | os_version: "10", 33 | browser: "Edge", 34 | browser_version: "17.0", 35 | }, 36 | BS_IE11: { 37 | name: "IE 11", 38 | base: "BrowserStack", 39 | os: "Windows", 40 | os_version: "10", 41 | browser: "IE", 42 | browser_version: "11.0", 43 | }, 44 | // Safari throws an error if you use replaceState more 45 | // than 100 times in 30 seconds :/ 46 | // BS_Safari: { 47 | // base: 'BrowserStack', 48 | // os: 'OS X', 49 | // os_version: 'Mojave', 50 | // browser: 'Safari', 51 | // browser_version: '12.1' 52 | // } 53 | // BS_iPhoneX: { 54 | // base: 'BrowserStack', 55 | // device: 'iPhone X', 56 | // os_version: '11', 57 | // real_mobile: true 58 | // }, 59 | // BS_iPhoneXS: { 60 | // base: 'BrowserStack', 61 | // device: 'iPhone XS', 62 | // os_version: '12', 63 | // real_mobile: true 64 | // }, 65 | }; 66 | 67 | config.set({ 68 | singleRun: true, 69 | customLaunchers: customLaunchers, 70 | browsers: ["Chrome" /*, 'Firefox'*/], 71 | frameworks: ["mocha" /*, 'webpack' */], 72 | reporters: ["mocha"], 73 | files: ["tests.webpack.js"], 74 | preprocessors: { 75 | "tests.webpack.js": ["webpack", "sourcemap"], 76 | }, 77 | webpack: { 78 | // TODO: Webpack 4+ 79 | // mode: 'none', 80 | devtool: "inline-source-map", 81 | resolve: { 82 | modules: [path.resolve(__dirname, "../"), "node_modules"], 83 | alias: { 84 | history: path.resolve(__dirname, "../build/history"), 85 | }, 86 | }, 87 | module: { 88 | rules: [ 89 | { 90 | test: /__tests__\/.*\.js$/, 91 | exclude: /node_modules/, 92 | use: { 93 | loader: "babel-loader", 94 | options: { 95 | presets: ["@babel/preset-env"], 96 | }, 97 | }, 98 | }, 99 | ], 100 | }, 101 | plugins: [ 102 | new webpack.DefinePlugin({ 103 | "process.env.NODE_ENV": JSON.stringify("test"), 104 | }), 105 | ], 106 | }, 107 | webpackServer: { 108 | noInfo: true, 109 | }, 110 | }); 111 | 112 | if (process.env.TRAVIS || process.env.USE_CLOUD) { 113 | config.browsers = Object.keys(customLaunchers); 114 | config.reporters = ["dots"]; 115 | config.concurrency = 2; 116 | config.browserDisconnectTimeout = 10000; 117 | config.browserDisconnectTolerance = 3; 118 | 119 | if (process.env.TRAVIS) { 120 | config.browserStack = { 121 | project: "history", 122 | build: process.env.TRAVIS_BRANCH, 123 | }; 124 | } else { 125 | config.browserStack = { 126 | project: "history", 127 | }; 128 | } 129 | } 130 | }; 131 | -------------------------------------------------------------------------------- /scripts/publish.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const execSync = require("child_process").execSync; 3 | 4 | const jsonfile = require("jsonfile"); 5 | const semver = require("semver"); 6 | 7 | const rootDir = path.resolve(__dirname, ".."); 8 | 9 | function invariant(cond, message) { 10 | if (!cond) throw new Error(message); 11 | } 12 | 13 | function getTaggedVersion() { 14 | let output = execSync("git tag --list --points-at HEAD").toString(); 15 | return output.replace(/^v|\n+$/g, ""); 16 | } 17 | 18 | async function ensureBuildVersion(packageName, version) { 19 | let file = path.join(rootDir, "build", packageName, "package.json"); 20 | let json = await jsonfile.readFile(file); 21 | invariant( 22 | json.version === version, 23 | `Package ${packageName} is on version ${json.version}, but should be on ${version}` 24 | ); 25 | } 26 | 27 | function publishBuild(packageName, tag) { 28 | let buildDir = path.join(rootDir, "build", packageName); 29 | console.log(); 30 | console.log(` npm publish ${buildDir} --tag ${tag}`); 31 | console.log(); 32 | execSync(`npm publish ${buildDir} --tag ${tag}`, { stdio: "inherit" }); 33 | } 34 | 35 | async function run() { 36 | try { 37 | // 0. Ensure we are in CI. We don't do this manually 38 | invariant( 39 | process.env.CI, 40 | `You should always run the publish script from the CI environment!` 41 | ); 42 | 43 | // 1. Get the current tag, which has the release version number 44 | let version = getTaggedVersion(); 45 | invariant( 46 | version !== "", 47 | "Missing release version. Run the version script first." 48 | ); 49 | 50 | // 2. Determine the appropriate npm tag to use 51 | let tag = semver.prerelease(version) == null ? "latest" : "next"; 52 | 53 | console.log(); 54 | console.log(` Publishing version ${version} to npm with tag "${tag}"`); 55 | 56 | // 3. Ensure build versions match the release version 57 | await ensureBuildVersion("history", version); 58 | 59 | // 4. Publish to npm 60 | publishBuild("history", tag); 61 | } catch (error) { 62 | console.log(); 63 | console.error(` ${error.message}`); 64 | console.log(); 65 | return 1; 66 | } 67 | 68 | return 0; 69 | } 70 | 71 | run().then((code) => { 72 | process.exit(code); 73 | }); 74 | -------------------------------------------------------------------------------- /scripts/rollup/history.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from "@rollup/plugin-babel"; 2 | import copy from "rollup-plugin-copy"; 3 | import prettier from "rollup-plugin-prettier"; 4 | import replace from "@rollup/plugin-replace"; 5 | import { terser } from "rollup-plugin-terser"; 6 | import typescript from "rollup-plugin-typescript2"; 7 | 8 | const PRETTY = !!process.env.PRETTY; 9 | const SOURCE_DIR = "packages/history"; 10 | const OUTPUT_DIR = "build/history"; 11 | 12 | const modules = [ 13 | { 14 | input: `${SOURCE_DIR}/index.ts`, 15 | output: { 16 | file: `${OUTPUT_DIR}/index.js`, 17 | format: "esm", 18 | sourcemap: !PRETTY, 19 | }, 20 | external: ["@babel/runtime/helpers/esm/extends"], 21 | plugins: [ 22 | typescript({ 23 | tsconfigDefaults: { 24 | compilerOptions: { 25 | declaration: true, 26 | }, 27 | }, 28 | }), 29 | babel({ 30 | exclude: /node_modules/, 31 | extensions: [".ts"], 32 | presets: [["@babel/preset-env", { loose: true }]], 33 | plugins: [ 34 | "babel-plugin-dev-expression", 35 | ["@babel/plugin-transform-runtime", { useESModules: true }], 36 | ], 37 | babelHelpers: "runtime", 38 | }), 39 | copy({ 40 | targets: [ 41 | { src: "README.md", dest: OUTPUT_DIR }, 42 | { src: "LICENSE", dest: OUTPUT_DIR }, 43 | { src: `${SOURCE_DIR}/package.json`, dest: OUTPUT_DIR }, 44 | ], 45 | verbose: true, 46 | }), 47 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 48 | }, 49 | ...["browser", "hash"].map((env) => { 50 | return { 51 | input: `${SOURCE_DIR}/${env}.ts`, 52 | output: { 53 | file: `${OUTPUT_DIR}/${env}.js`, 54 | format: "esm", 55 | sourcemap: !PRETTY, 56 | }, 57 | plugins: [ 58 | typescript({ 59 | tsconfigDefaults: { 60 | compilerOptions: { 61 | declaration: true, 62 | }, 63 | }, 64 | }), 65 | babel({ 66 | exclude: /node_modules/, 67 | extensions: [".ts"], 68 | presets: [["@babel/preset-env", { loose: true }]], 69 | plugins: ["babel-plugin-dev-expression"], 70 | babelHelpers: "bundled", 71 | }), 72 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 73 | }; 74 | }), 75 | ]; 76 | 77 | const webModules = [ 78 | { 79 | input: `${SOURCE_DIR}/index.ts`, 80 | output: { 81 | file: `${OUTPUT_DIR}/history.development.js`, 82 | format: "esm", 83 | sourcemap: !PRETTY, 84 | }, 85 | plugins: [ 86 | typescript({ 87 | tsconfigOverride: { 88 | compilerOptions: { 89 | target: "es2016", 90 | }, 91 | }, 92 | }), 93 | babel({ 94 | exclude: /node_modules/, 95 | extensions: [".ts"], 96 | presets: ["@babel/preset-modules"], 97 | plugins: ["babel-plugin-dev-expression"], 98 | babelHelpers: "bundled", 99 | }), 100 | replace({ 101 | "process.env.NODE_ENV": JSON.stringify("development"), 102 | preventAssignment: false, 103 | }), 104 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 105 | }, 106 | { 107 | input: `${SOURCE_DIR}/index.ts`, 108 | output: { 109 | file: `${OUTPUT_DIR}/history.production.min.js`, 110 | format: "esm", 111 | sourcemap: !PRETTY, 112 | }, 113 | plugins: [ 114 | typescript({ 115 | tsconfigOverride: { 116 | compilerOptions: { 117 | target: "es2016", 118 | }, 119 | }, 120 | }), 121 | babel({ 122 | exclude: /node_modules/, 123 | extensions: [".ts"], 124 | presets: ["@babel/preset-modules"], 125 | plugins: ["babel-plugin-dev-expression"], 126 | babelHelpers: "bundled", 127 | }), 128 | replace({ 129 | "process.env.NODE_ENV": JSON.stringify("production"), 130 | preventAssignment: false, 131 | }), 132 | terser({ ecma: 8, safari10: true }), 133 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 134 | }, 135 | ]; 136 | 137 | const globals = [ 138 | { 139 | input: `${SOURCE_DIR}/index.ts`, 140 | output: { 141 | file: `${OUTPUT_DIR}/umd/history.development.js`, 142 | format: "umd", 143 | sourcemap: !PRETTY, 144 | name: "HistoryLibrary", 145 | }, 146 | plugins: [ 147 | typescript(), 148 | babel({ 149 | exclude: /node_modules/, 150 | extensions: [".ts"], 151 | presets: [["@babel/preset-env", { loose: true }]], 152 | plugins: ["babel-plugin-dev-expression"], 153 | babelHelpers: "bundled", 154 | }), 155 | replace({ 156 | "process.env.NODE_ENV": JSON.stringify("development"), 157 | preventAssignment: false, 158 | }), 159 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 160 | }, 161 | { 162 | input: `${SOURCE_DIR}/index.ts`, 163 | output: { 164 | file: `${OUTPUT_DIR}/umd/history.production.min.js`, 165 | format: "umd", 166 | sourcemap: !PRETTY, 167 | name: "HistoryLibrary", 168 | }, 169 | plugins: [ 170 | typescript(), 171 | babel({ 172 | exclude: /node_modules/, 173 | extensions: [".ts"], 174 | presets: [["@babel/preset-env", { loose: true }]], 175 | plugins: ["babel-plugin-dev-expression"], 176 | babelHelpers: "bundled", 177 | }), 178 | replace({ 179 | "process.env.NODE_ENV": JSON.stringify("production"), 180 | preventAssignment: false, 181 | }), 182 | terser(), 183 | ].concat(PRETTY ? prettier({ parser: "babel" }) : []), 184 | }, 185 | ]; 186 | 187 | const node = [ 188 | { 189 | input: `${SOURCE_DIR}/node-main.js`, 190 | output: { 191 | file: `${OUTPUT_DIR}/main.js`, 192 | format: "cjs", 193 | }, 194 | plugins: PRETTY ? prettier({ parser: "babel" }) : [], 195 | }, 196 | ]; 197 | 198 | const config = [...modules, ...webModules, ...globals, ...node]; 199 | 200 | export default config; 201 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const execSync = require("child_process").execSync; 3 | 4 | let karmaConfig = path.resolve(__dirname, "karma.conf.js"); 5 | 6 | execSync(`karma start ${karmaConfig}`, { 7 | env: process.env, 8 | stdio: "inherit", 9 | }); 10 | -------------------------------------------------------------------------------- /scripts/tests.webpack.js: -------------------------------------------------------------------------------- 1 | var context = require.context("../packages", true, /-test\.js$/); 2 | context.keys().forEach(context); 3 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const execSync = require("child_process").execSync; 3 | 4 | const chalk = require("chalk"); 5 | const Confirm = require("prompt-confirm"); 6 | const jsonfile = require("jsonfile"); 7 | const semver = require("semver"); 8 | 9 | const rootDir = path.resolve(__dirname, ".."); 10 | 11 | function packageJson(packageName) { 12 | return path.join(rootDir, "packages", packageName, "package.json"); 13 | } 14 | 15 | function invariant(cond, message) { 16 | if (!cond) throw new Error(message); 17 | } 18 | 19 | function ensureCleanWorkingDirectory() { 20 | let status = execSync(`git status --porcelain`).toString().trim(); 21 | let lines = status.split("\n"); 22 | invariant( 23 | lines.every((line) => line === "" || line.startsWith("?")), 24 | "Working directory is not clean. Please commit or stash your changes." 25 | ); 26 | } 27 | 28 | function getNextVersion(currentVersion, givenVersion, prereleaseId) { 29 | invariant( 30 | givenVersion != null, 31 | `Missing next version. Usage: node version.js [nextVersion]` 32 | ); 33 | 34 | if (/^pre/.test(givenVersion)) { 35 | invariant( 36 | prereleaseId != null, 37 | `Missing prerelease id. Usage: node version.js ${givenVersion} [prereleaseId]` 38 | ); 39 | } 40 | 41 | let nextVersion = semver.inc(currentVersion, givenVersion, prereleaseId); 42 | 43 | invariant(nextVersion != null, `Invalid version specifier: ${givenVersion}`); 44 | 45 | return nextVersion; 46 | } 47 | 48 | async function prompt(question) { 49 | let confirm = new Confirm(question); 50 | let answer = await confirm.run(); 51 | return answer; 52 | } 53 | 54 | async function getPackageVersion(packageName) { 55 | let file = packageJson(packageName); 56 | let json = await jsonfile.readFile(file); 57 | return json.version; 58 | } 59 | 60 | async function updatePackageConfig(packageName, transform) { 61 | let file = packageJson(packageName); 62 | let json = await jsonfile.readFile(file); 63 | transform(json); 64 | await jsonfile.writeFile(file, json, { spaces: 2 }); 65 | } 66 | 67 | async function run() { 68 | try { 69 | let args = process.argv.slice(2); 70 | let givenVersion = args[0]; 71 | let prereleaseId = args[1]; 72 | 73 | // 0. Make sure the working directory is clean 74 | ensureCleanWorkingDirectory(); 75 | 76 | // 1. Get the next version number 77 | let currentVersion = await getPackageVersion("history"); 78 | let version = semver.valid(givenVersion); 79 | if (version == null) { 80 | version = getNextVersion(currentVersion, givenVersion, prereleaseId); 81 | } 82 | 83 | // 2. Confirm the next version number 84 | let answer = await prompt( 85 | `Are you sure you want to bump version ${currentVersion} to ${version}? [Yn] ` 86 | ); 87 | 88 | if (answer === false) return 0; 89 | 90 | // 3. Update history version 91 | await updatePackageConfig("history", (config) => { 92 | config.version = version; 93 | }); 94 | console.log(chalk.green(` Updated history to version ${version}`)); 95 | 96 | // 4. Commit and tag 97 | execSync(`git commit --all --message="Version ${version}"`); 98 | execSync(`git tag -a -m "Version ${version}" v${version}`); 99 | console.log(chalk.green(` Committed and tagged version ${version}`)); 100 | } catch (error) { 101 | console.log(); 102 | console.error(chalk.red(` ${error.message}`)); 103 | console.log(); 104 | return 1; 105 | } 106 | 107 | return 0; 108 | } 109 | 110 | run().then((code) => { 111 | process.exit(code); 112 | }); 113 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["packages", "types"], 3 | "compilerOptions": { 4 | "lib": ["dom", "esnext"], 5 | "module": "esnext", 6 | "target": "es2018", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "strict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /types/global.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-unused-vars 2 | declare const __DEV__: boolean; 3 | --------------------------------------------------------------------------------