├── .npmignore ├── .eslintignore ├── index.ts ├── global.d.ts ├── .prettierrc ├── docs ├── globals.md ├── functions │ └── useTravel.md └── README.md ├── vitest.config.ts ├── .editorconfig ├── .github └── workflows │ ├── nodejs.yml │ ├── npm-publish.yml │ ├── pull-request.yml │ └── tests.yml ├── LICENSE ├── rollup.config.ts ├── .eslintrc ├── .gitignore ├── test ├── use-travel-store.test.ts ├── base.test.ts ├── validation.test.ts ├── edge-cases.test.ts └── index.test.ts ├── package.json ├── tsconfig.json ├── src └── index.ts └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | example 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | export * from './src'; 2 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare var __DEV__: boolean 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /docs/globals.md: -------------------------------------------------------------------------------- 1 | [**use-travel**](README.md) • **Docs** 2 | 3 | *** 4 | 5 | # use-travel 6 | 7 | ## Functions 8 | 9 | - [useTravel](functions/useTravel.md) 10 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | coverage: { 8 | provider: 'v8', 9 | reporter: ['text', 'lcov'], 10 | reportsDirectory: './coverage', 11 | }, 12 | }, 13 | define: { 14 | __DEV__: false, 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | # install EditorConfig for VS Code extension 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_size = 2 10 | indent_style = space 11 | insert_final_newline = true 12 | max_line_length = 80 13 | trim_trailing_whitespace = true 14 | 15 | [*.md] 16 | max_line_length = 0 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x, 22.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: yarn install, build, and test 21 | run: | 22 | yarn install 23 | yarn build 24 | yarn test 25 | env: 26 | CI: true 27 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Package to npmjs 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | publish-npm: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '20.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: yarn 18 | - run: yarn build 19 | - run: yarn test 20 | - run: npm publish --provenance 21 | env: 22 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} 23 | CI: true 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | on: ["pull_request"] 2 | 3 | name: Test Coverage 4 | permissions: 5 | contents: read 6 | pull-requests: write 7 | 8 | jobs: 9 | 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | 15 | - uses: actions/checkout@v1 16 | 17 | - name: Use Node.js 20.x 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 20.x 21 | 22 | - name: yarn install, yarn test:coverage 23 | run: | 24 | yarn install 25 | yarn test --coverage 26 | 27 | - name: Coverage 28 | uses: romeovs/lcov-reporter-action@v0.4.0 29 | with: 30 | lcov-file: ./coverage/lcov.info 31 | -------------------------------------------------------------------------------- /docs/functions/useTravel.md: -------------------------------------------------------------------------------- 1 | [**use-travel**](../README.md) • **Docs** 2 | 3 | *** 4 | 5 | [use-travel](../globals.md) / useTravel 6 | 7 | # Function: useTravel() 8 | 9 | > **useTravel**\<`S`, `F`, `A`\>(`initialState`, `_options`): `Result`\<`S`, `F`, `A`\> 10 | 11 | A hook to travel in the history of a state 12 | 13 | ## Type parameters 14 | 15 | • **S** 16 | 17 | • **F** *extends* `boolean` 18 | 19 | • **A** *extends* `boolean` 20 | 21 | ## Parameters 22 | 23 | • **initialState**: `S` 24 | 25 | • **\_options**: `Options`\<`F`, `A`\>= `{}` 26 | 27 | ## Returns 28 | 29 | `Result`\<`S`, `F`, `A`\> 30 | 31 | ## Source 32 | 33 | [index.ts:102](https://github.com/mutativejs/use-travel/blob/e8f4c44889f0b0e45c85b07be9ea0461cd0aba85/src/index.ts#L102) 34 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | 6 | name: Test with Coveralls 7 | jobs: 8 | 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | 14 | - uses: actions/checkout@v1 15 | 16 | - name: Use Node.js 20.x 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: 20.x 20 | 21 | - name: yarn install, yarn test:coverage 22 | run: | 23 | yarn install 24 | echo repo_token: ${{ secrets.COVERALLS_REPO_TOKEN }} > .coveralls.yml 25 | yarn test:coverage 26 | 27 | - name: Coveralls 28 | uses: coverallsapp/github-action@master 29 | env: 30 | NODE_COVERALLS_DEBUG: 1 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Lin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import replace from '@rollup/plugin-replace'; 4 | import terser from '@rollup/plugin-terser'; 5 | import pkg from './package.json'; 6 | 7 | const input = './dist/index.js'; 8 | 9 | export default { 10 | input, 11 | output: [ 12 | { 13 | format: 'cjs', 14 | exports: 'auto', 15 | file: 'dist/index.cjs.js', 16 | sourcemap: true, 17 | }, 18 | { 19 | format: 'es', 20 | file: 'dist/index.esm.js', 21 | sourcemap: true, 22 | }, 23 | { 24 | format: 'umd', 25 | name: pkg.name 26 | .split('-') 27 | .map(([s, ...rest]) => [s.toUpperCase(), ...rest].join('')) 28 | .join(''), 29 | file: pkg.unpkg, 30 | sourcemap: true, 31 | globals: { 32 | mutative: 'Mutative', 33 | react: 'React', 34 | }, 35 | exports: 'named', 36 | }, 37 | ], 38 | plugins: [ 39 | resolve(), 40 | commonjs(), 41 | replace({ 42 | __DEV__: 'false', 43 | preventAssignment: true, 44 | }), 45 | terser(), 46 | ], 47 | external: ['mutative', 'react'], 48 | }; 49 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "airbnb", 10 | "prettier", 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "globals": { 16 | "Atomics": "readonly", 17 | "SharedArrayBuffer": "readonly" 18 | }, 19 | "parser": "@typescript-eslint/parser", 20 | "parserOptions": { 21 | "ecmaVersion": 2019, 22 | "sourceType": "module" 23 | }, 24 | "plugins": [ 25 | "prettier", 26 | "@typescript-eslint" 27 | ], 28 | "rules": { 29 | "no-useless-constructor": 0, 30 | "class-methods-use-this": 0, 31 | "no-underscore-dangle": 0, 32 | "@typescript-eslint/no-non-null-assertion": 0, 33 | "@typescript-eslint/interface-name-prefix": 0, 34 | "@typescript-eslint/explicit-function-return-type": 0, 35 | "max-classes-per-file": 0, 36 | "no-await-in-loop": "off", 37 | "no-restricted-syntax": "off", 38 | "import/no-extraneous-dependencies": [ 39 | "error", 40 | { 41 | "devDependencies": true 42 | } 43 | ], 44 | "import/prefer-default-export": "off", 45 | "prettier/prettier": [ 46 | "error" 47 | ], 48 | "import/extensions": [ 49 | "error", 50 | "ignorePackages", 51 | { 52 | "js": "never", 53 | "ts": "never" 54 | } 55 | ] 56 | }, 57 | "settings": { 58 | "import/resolver": { 59 | "node": { 60 | "extensions": [ 61 | ".js", 62 | ".ts" 63 | ] 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | -------------------------------------------------------------------------------- /test/use-travel-store.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { act, renderHook } from '@testing-library/react'; 3 | import { Travels } from 'travels'; 4 | import { useTravelStore } from '../src/index'; 5 | 6 | describe('useTravelStore', () => { 7 | it('throws when used with a mutable Travels instance', () => { 8 | const travels = new Travels({ count: 0 }, { mutable: true }); 9 | 10 | expect(() => 11 | renderHook(() => useTravelStore(travels)) 12 | ).toThrowError( 13 | /useTravelStore only supports immutable Travels instances/ 14 | ); 15 | }); 16 | 17 | it('syncs state and controls with an immutable Travels instance', () => { 18 | const travels = new Travels({ count: 0 }); 19 | 20 | const { result } = renderHook(() => useTravelStore(travels)); 21 | 22 | let [state, setState, controls] = result.current; 23 | expect(state).toEqual({ count: 0 }); 24 | expect(typeof setState).toBe('function'); 25 | expect(controls.getHistory()).toEqual(travels.getHistory()); 26 | 27 | act(() => 28 | setState((draft) => { 29 | draft.count = 1; 30 | }) 31 | ); 32 | [state, setState, controls] = result.current; 33 | 34 | expect(state).toEqual({ count: 1 }); 35 | expect(travels.getState()).toEqual({ count: 1 }); 36 | expect(controls.getHistory()).toEqual(travels.getHistory()); 37 | 38 | act(() => 39 | travels.setState((draft) => { 40 | draft.count = 42; 41 | }) 42 | ); 43 | [state, setState, controls] = result.current; 44 | 45 | expect(state).toEqual({ count: 42 }); 46 | expect(controls.getHistory()).toEqual(travels.getHistory()); 47 | }); 48 | 49 | it('exposes manual archive controls when autoArchive is disabled', () => { 50 | const travels = new Travels( 51 | { todos: [] as string[] }, 52 | { autoArchive: false } 53 | ); 54 | 55 | const { result } = renderHook(() => useTravelStore(travels)); 56 | 57 | let [state, setState, controls] = result.current; 58 | const manualControls = controls as ReturnType; 59 | expect(typeof (manualControls as any).archive).toBe('function'); 60 | expect(typeof (manualControls as any).canArchive).toBe('function'); 61 | expect((manualControls as any).canArchive()).toBe(false); 62 | 63 | act(() => 64 | setState((draft) => { 65 | draft.todos.push('todo 1'); 66 | }) 67 | ); 68 | [state, setState, controls] = result.current; 69 | 70 | const manualControlsAfterUpdate = 71 | controls as ReturnType; 72 | 73 | expect(state.todos).toEqual(['todo 1']); 74 | expect((manualControlsAfterUpdate as any).canArchive()).toBe(true); 75 | 76 | act(() => (manualControlsAfterUpdate as any).archive()); 77 | [state, setState, controls] = result.current; 78 | 79 | const manualControlsAfterArchive = 80 | controls as ReturnType; 81 | 82 | expect((manualControlsAfterArchive as any).canArchive()).toBe(false); 83 | expect(manualControlsAfterArchive.getHistory()).toEqual( 84 | travels.getHistory() 85 | ); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-travel", 3 | "version": "1.6.4", 4 | "description": "A React hook for state time travel with undo, redo, reset and archive functionalities.", 5 | "main": "dist/index.cjs.js", 6 | "unpkg": "dist/index.umd.js", 7 | "types": "dist/index.d.ts", 8 | "umd:main": "dist/index.umd.js", 9 | "module": "dist/index.esm.js", 10 | "jsnext:main": "dist/index.esm.js", 11 | "react-native": "dist/index.esm.js", 12 | "typings": "dist/index.d.ts", 13 | "source": "src/index.ts", 14 | "sideEffects": false, 15 | "files": [ 16 | "dist" 17 | ], 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "scripts": { 22 | "test": "vitest run", 23 | "test:coverage": "vitest run --coverage && coveralls < coverage/lcov.info", 24 | "clean": "rimraf dist", 25 | "build": "yarn clean && tsc --skipLibCheck && yarn build:prod", 26 | "build:prod": "NODE_ENV=production rollup --config --bundleConfigAsCjs", 27 | "build:doc": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts", 28 | "commit": "yarn git-cz" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/mutativejs/use-travel.git" 33 | }, 34 | "author": "unadlib", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/mutativejs/use-travel/issues" 38 | }, 39 | "homepage": "https://github.com/mutativejs/use-travel#readme", 40 | "keywords": [ 41 | "use-undo", 42 | "undo", 43 | "redo", 44 | "time travel", 45 | "mutative" 46 | ], 47 | "devDependencies": { 48 | "@rollup/plugin-commonjs": "^28.0.6", 49 | "@rollup/plugin-node-resolve": "^16.0.1", 50 | "@rollup/plugin-replace": "^6.0.2", 51 | "@rollup/plugin-terser": "^0.4.4", 52 | "@testing-library/react": "^14.2.1", 53 | "@types/node": "^18.15.5", 54 | "@types/react": "^18.2.66", 55 | "@typescript-eslint/eslint-plugin": "^8.44.1", 56 | "@typescript-eslint/parser": "^8.44.1", 57 | "@vitest/coverage-v8": "^2.1.4", 58 | "commitizen": "^4.3.0", 59 | "coveralls": "^3.1.1", 60 | "eslint": "^9.3.0", 61 | "eslint-config-airbnb": "^19.0.4", 62 | "eslint-config-prettier": "^9.1.0", 63 | "eslint-plugin-import": "^2.29.1", 64 | "eslint-plugin-prettier": "^5.1.3", 65 | "jsdom": "^27.0.0", 66 | "mutative": "^1.3.0", 67 | "prettier": "^3.2.5", 68 | "react": "^18.2.0", 69 | "react-dom": "^18.2.0", 70 | "rimraf": "^4.4.0", 71 | "rollup": "^4.52.3", 72 | "travels": "^0.9.0", 73 | "ts-node": "^10.9.2", 74 | "tslib": "^2.8.1", 75 | "typedoc": "^0.26.11", 76 | "typedoc-plugin-markdown": "^4.2.10", 77 | "typescript": "^5.9.2", 78 | "vitest": "^2.1.4", 79 | "yargs": "^17.7.2" 80 | }, 81 | "config": { 82 | "commitizen": { 83 | "path": "cz-conventional-changelog" 84 | } 85 | }, 86 | "peerDependencies": { 87 | "@types/react": "^17.0 || ^18.0 || ^19.0", 88 | "mutative": "^1.3.0", 89 | "react": "^17.0 || ^18.0 || ^19.0", 90 | "travels": "^0.9.0" 91 | }, 92 | "dependencies": { 93 | "@types/use-sync-external-store": "^1.5.0", 94 | "use-sync-external-store": "^1.6.0" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es2015" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 6 | "module": "es2015" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | "lib": [ 8 | "ES2019", 9 | "DOM" 10 | ] /* Specify library files to be included in the compilation. */, 11 | // "allowJs": true, /* Allow javascript files to be compiled. */ 12 | // "checkJs": true, /* Report errors in .js files. */ 13 | // "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | "declaration": true /* Generates corresponding '.d.ts' file. */, 15 | "declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */, 16 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 17 | // "outFile": "./", /* Concatenate and emit output to single file. */ 18 | "outDir": "./dist" /* Redirect output structure to the directory. */, 19 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 20 | // "composite": true, /* Enable project compilation */ 21 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 25 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | 28 | /* Strict Type-Checking Options */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 31 | // "strictNullChecks": true, /* Enable strict null checks. */ 32 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 33 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 34 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 35 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 36 | "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 37 | 38 | /* Additional Checks */ 39 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 40 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 41 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 42 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 43 | 44 | /* Module Resolution Options */ 45 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 46 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 47 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 48 | "rootDirs": [ 49 | "node_modules/@types" 50 | ] /* List of root folders whose combined content represents the structure of the project at runtime. */, 51 | // "types": [ 52 | // ], /* Type declaration files to be included in compilation. */ 53 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 54 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 55 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 56 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 57 | 58 | /* Source Map Options */ 59 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 60 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 61 | "inlineSourceMap": true /* Emit a single file with source maps instead of having a separate file. */, 62 | "inlineSources": true /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */, 63 | 64 | /* Experimental Options */ 65 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 66 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 67 | "useDefineForClassFields": false, 68 | 69 | /* Advanced Options */ 70 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 71 | }, 72 | "include": ["src/*", "global.d.ts"] 73 | } 74 | -------------------------------------------------------------------------------- /test/base.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest'; 2 | import { act, renderHook } from '@testing-library/react'; 3 | import { useTravel } from '../src/index'; 4 | 5 | describe('useTravel', () => { 6 | it('[useTravel] base state updates with autoArchive=true should advance position for each setState call', () => { 7 | const { result } = renderHook(() => 8 | useTravel({ count: 0 }, { autoArchive: true }) 9 | ); 10 | 11 | let [state, setState, controls] = result.current; 12 | 13 | // Initial state 14 | expect(state).toEqual({ count: 0 }); 15 | expect(controls.position).toBe(0); 16 | expect(controls.getHistory()).toEqual([{ count: 0 }]); 17 | 18 | act(() => { 19 | setState((draft) => { 20 | draft.count += 1; 21 | }); 22 | }); 23 | 24 | [state, setState, controls] = result.current; 25 | 26 | act(() => { 27 | setState((draft) => { 28 | draft.count += 1; 29 | }); 30 | }); 31 | 32 | [state, setState, controls] = result.current; 33 | 34 | act(() => { 35 | setState((draft) => { 36 | draft.count += 1; 37 | }); 38 | }); 39 | 40 | [state, setState, controls] = result.current; 41 | 42 | expect(state).toEqual({ count: 3 }); 43 | expect(controls.position).toBe(3); 44 | 45 | expect(controls.getHistory()).toEqual([ 46 | { count: 0 }, 47 | { count: 1 }, 48 | { count: 2 }, 49 | { count: 3 }, 50 | ]); 51 | }); 52 | 53 | it('[useTravel]: base state updates with autoArchive=false should advance position for each setState call', () => { 54 | const { result } = renderHook(() => 55 | useTravel({ count: 0 }, { autoArchive: false }) 56 | ); 57 | 58 | let [state, setState, controls] = result.current; 59 | 60 | // Initial state 61 | expect(state).toEqual({ count: 0 }); 62 | expect(controls.position).toBe(0); 63 | expect(controls.getHistory()).toEqual([{ count: 0 }]); 64 | 65 | // Simulate batched setState calls in same event handler 66 | act(() => { 67 | setState({ count: 1 }); 68 | }); 69 | 70 | [state, setState, controls] = result.current; 71 | 72 | act(() => { 73 | setState({ count: 2 }); 74 | }); 75 | 76 | [state, setState, controls] = result.current; 77 | 78 | act(() => { 79 | setState({ count: 3 }); 80 | }); 81 | 82 | [state, setState, controls] = result.current; 83 | 84 | expect(state).toEqual({ count: 3 }); 85 | 86 | expect(controls.position).toBe(1); 87 | 88 | // Archive the final state 89 | act(() => { 90 | controls.archive(); 91 | }); 92 | [state, setState, controls] = result.current; 93 | 94 | expect(controls.position).toBe(1); 95 | expect(controls.getHistory()).toEqual([{ count: 0 }, { count: 3 }]); 96 | 97 | act(() => { 98 | setState((draft) => { 99 | draft.count += 1; 100 | }); 101 | }); 102 | 103 | [state, setState, controls] = result.current; 104 | 105 | act(() => { 106 | setState((draft) => { 107 | draft.count += 1; 108 | }); 109 | }); 110 | 111 | [state, setState, controls] = result.current; 112 | 113 | act(() => { 114 | setState((draft) => { 115 | draft.count += 1; 116 | }); 117 | }); 118 | 119 | [state, setState, controls] = result.current; 120 | 121 | expect(state).toEqual({ count: 6 }); 122 | expect(controls.position).toBe(2); 123 | expect(controls.getHistory()).toEqual([ 124 | { count: 0 }, 125 | { count: 3 }, 126 | { count: 6 }, 127 | ]); 128 | expect(controls.canArchive()).toBe(true); 129 | act(() => controls.archive()); 130 | 131 | [state, setState, controls] = result.current; 132 | expect(state).toEqual({ count: 6 }); 133 | expect(controls.position).toBe(2); 134 | expect(controls.getHistory()).toEqual([ 135 | { count: 0 }, 136 | { count: 3 }, 137 | { count: 6 }, 138 | ]); 139 | expect(controls.canArchive()).toBe(false); 140 | }); 141 | 142 | it('[useTravel]: setState calls after navigation truncate newly added patches incorrectly', () => { 143 | const { result } = renderHook(() => 144 | useTravel({ count: 0 }, { autoArchive: true }) 145 | ); 146 | 147 | let [state, setState, controls] = result.current; 148 | 149 | // Build some history first 150 | act(() => setState({ count: 1 })); 151 | [state, setState, controls] = result.current; 152 | act(() => setState({ count: 2 })); 153 | [state, setState, controls] = result.current; 154 | act(() => setState({ count: 3 })); 155 | 156 | [state, setState, controls] = result.current; 157 | expect(controls.position).toBe(3); 158 | expect(controls.getHistory()).toEqual([ 159 | { count: 0 }, 160 | { count: 1 }, 161 | { count: 2 }, 162 | { count: 3 }, 163 | ]); 164 | 165 | // Go back to middle position 166 | act(() => controls.go(1)); 167 | [state, setState, controls] = result.current; 168 | expect(state).toEqual({ count: 1 }); 169 | expect(controls.position).toBe(1); 170 | 171 | expect(() => { 172 | act(() => { 173 | setState({ count: 10 }); 174 | setState({ count: 20 }); 175 | }); 176 | }).toThrow( 177 | 'setState cannot be called multiple times in the same render cycle.' 178 | ); 179 | }); 180 | 181 | it('[useTravel]: Multiple rapid setState calls should each increment position correctly', () => { 182 | const { result } = renderHook(() => useTravel(0, { autoArchive: true })); 183 | 184 | let [state, setState, controls] = result.current; 185 | 186 | expect(() => { 187 | act(() => { 188 | setState(1); 189 | setState(2); 190 | }); 191 | }).toThrow( 192 | 'setState cannot be called multiple times in the same render cycle.' 193 | ); 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'; 2 | import type { 3 | TravelPatches, 4 | TravelsOptions, 5 | ManualTravelsControls, 6 | TravelsControls, 7 | Value, 8 | Updater, 9 | PatchesOption, 10 | } from 'travels'; 11 | import { Travels } from 'travels'; 12 | import { useSyncExternalStore } from 'use-sync-external-store/shim'; 13 | 14 | export type { TravelPatches }; 15 | 16 | type Result< 17 | S, 18 | F extends boolean, 19 | A extends boolean, 20 | P extends PatchesOption = {}, 21 | > = [ 22 | Value, 23 | (updater: Updater) => void, 24 | A extends false ? ManualTravelsControls : TravelsControls, 25 | ]; 26 | 27 | /** 28 | * Creates a component-scoped {@link Travels} instance with undo/redo support and returns its reactive API. 29 | * 30 | * The hook memoises the underlying `Travels` instance per component, wires it to React's lifecycle, and forces 31 | * re-renders whenever the managed state changes. Consumers receive a tuple containing the current state, a `setState` 32 | * updater that accepts either a mutative draft function or partial state, and the history controls exposed by 33 | * {@link Travels}. 34 | * 35 | * @typeParam S - Shape of the state managed by the travel store. 36 | * @typeParam F - Whether draft freezing is enabled. 37 | * @typeParam A - Whether the instance auto-archives changes; determines the controls contract. 38 | * @typeParam P - Additional patches configuration forwarded to Mutative. 39 | * @param initialState - Value used to initialise the travel store. 40 | * @param _options - Optional configuration mirrored from {@link Travels}. 41 | * @returns A tuple with the current state, typed updater, and history controls. 42 | * @throws {Error} When `setState` is invoked multiple times within the same render cycle (development-only guard). 43 | */ 44 | export function useTravel( 45 | initialState: S 46 | ): [Value, (updater: Updater) => void, TravelsControls]; 47 | export function useTravel< 48 | S, 49 | F extends boolean, 50 | A extends boolean, 51 | P extends PatchesOption = {}, 52 | >( 53 | initialState: S, 54 | options: Omit, 'autoArchive' | 'mutable'> & { 55 | autoArchive?: true; 56 | } 57 | ): [Value, (updater: Updater) => void, TravelsControls]; 58 | export function useTravel< 59 | S, 60 | F extends boolean, 61 | A extends boolean, 62 | P extends PatchesOption = {}, 63 | >( 64 | initialState: S, 65 | options: Omit, 'autoArchive' | 'mutable'> & { 66 | autoArchive: false; 67 | } 68 | ): [Value, (updater: Updater) => void, ManualTravelsControls]; 69 | export function useTravel< 70 | S, 71 | F extends boolean, 72 | A extends boolean, 73 | P extends PatchesOption = {}, 74 | >(initialState: S, _options: TravelsOptions = {}): Result { 75 | if (__DEV__) { 76 | const { maxHistory = 10, initialPosition = 0, initialPatches } = _options; 77 | 78 | if (maxHistory <= 0) { 79 | console.error( 80 | `useTravel: maxHistory must be a positive number, but got ${maxHistory}` 81 | ); 82 | } 83 | 84 | if (initialPosition < 0) { 85 | console.error( 86 | `useTravel: initialPosition must be non-negative, but got ${initialPosition}` 87 | ); 88 | } 89 | 90 | if (initialPatches) { 91 | if ( 92 | !Array.isArray(initialPatches.patches) || 93 | !Array.isArray(initialPatches.inversePatches) 94 | ) { 95 | console.error( 96 | `useTravel: initialPatches must have 'patches' and 'inversePatches' arrays` 97 | ); 98 | } else if ( 99 | initialPatches.patches.length !== initialPatches.inversePatches.length 100 | ) { 101 | console.error( 102 | `useTravel: initialPatches.patches and initialPatches.inversePatches must have the same length` 103 | ); 104 | } 105 | } 106 | } 107 | 108 | // Create Travels instance (only once) 109 | const travelsRef = useRef>(); 110 | if (!travelsRef.current) { 111 | travelsRef.current = new Travels(initialState, { 112 | ..._options, 113 | mutable: false, 114 | }); 115 | } 116 | 117 | // Force re-render when state changes 118 | const [, forceUpdate] = useReducer((x: number) => x + 1, 0); 119 | 120 | // Track if setState has been called in the current render cycle 121 | const setStateCalledInRender = useRef(false); 122 | 123 | // Reset the flag at the start of each render cycle 124 | useEffect(() => { 125 | setStateCalledInRender.current = false; 126 | }); 127 | 128 | // Subscribe to state changes 129 | useEffect(() => { 130 | const travels = travelsRef.current!; 131 | const unsubscribe = travels.subscribe(() => { 132 | forceUpdate(); 133 | }); 134 | return unsubscribe; 135 | }, []); 136 | 137 | const travels = travelsRef.current; 138 | 139 | // Wrap setState to prevent multiple calls in the same render cycle 140 | const cachedSetState = useCallback( 141 | (updater: Updater) => { 142 | if (setStateCalledInRender.current) { 143 | throw new Error( 144 | 'setState cannot be called multiple times in the same render cycle.' 145 | ); 146 | } 147 | setStateCalledInRender.current = true; 148 | travels.setState(updater); 149 | }, 150 | [travels] 151 | ); 152 | 153 | // Get the current state 154 | const state = travels.getState(); 155 | 156 | // Create controls object with memoization 157 | const cachedControls = useMemo(() => { 158 | const baseControls = travels.getControls(); 159 | 160 | // Create a proxy-like object that always gets fresh values 161 | const controls: any = { 162 | get position() { 163 | return travels.getPosition(); 164 | }, 165 | getHistory: () => baseControls.getHistory(), 166 | get patches() { 167 | return travels.getPatches(); 168 | }, 169 | back: (amount?: number) => baseControls.back(amount), 170 | forward: (amount?: number) => baseControls.forward(amount), 171 | reset: () => baseControls.reset(), 172 | go: (position: number) => baseControls.go(position), 173 | canBack: () => baseControls.canBack(), 174 | canForward: () => baseControls.canForward(), 175 | // Always include archive and canArchive methods for compatibility 176 | // Even in autoArchive mode, archive() can be called (but will warn) 177 | archive: () => { 178 | if ('archive' in baseControls) { 179 | baseControls.archive(); 180 | } else { 181 | travels.archive(); 182 | } 183 | }, 184 | canArchive: () => { 185 | if ('canArchive' in baseControls) { 186 | return baseControls.canArchive(); 187 | } 188 | return travels.canArchive(); 189 | }, 190 | }; 191 | 192 | return controls; 193 | }, [travels]); 194 | 195 | return [state, cachedSetState, cachedControls] as Result; 196 | } 197 | 198 | /** 199 | * Subscribes to an existing {@link Travels} store and bridges it into React via `useSyncExternalStore`. 200 | * 201 | * The hook keeps React in sync with the store's state and exposes the same tuple shape as {@link useTravel}, but it 202 | * does not create or manage the store lifecycle. Mutable Travels instances are rejected because they reuse the same 203 | * state reference, which prevents React from observing updates. 204 | * 205 | * @typeParam S - Shape of the state managed by the travel store. 206 | * @typeParam F - Whether draft freezing is enabled. 207 | * @typeParam A - Whether the instance auto-archives changes; determines the controls contract. 208 | * @typeParam P - Additional patches configuration forwarded to Mutative. 209 | * @param travels - Existing {@link Travels} instance to bind to React. 210 | * @returns A tuple containing the current state, typed updater, and history controls. 211 | * @throws {Error} If the provided `Travels` instance was created with `mutable: true`. 212 | */ 213 | export function useTravelStore< 214 | S, 215 | F extends boolean, 216 | A extends boolean, 217 | P extends PatchesOption = {}, 218 | >( 219 | travels: Travels 220 | ): [ 221 | Value, 222 | (updater: Updater) => void, 223 | A extends false ? ManualTravelsControls : TravelsControls, 224 | ] { 225 | const isMutable = Boolean((travels as any)?.mutable); 226 | 227 | if (isMutable) { 228 | throw new Error( 229 | 'useTravelStore only supports immutable Travels instances. Remove `mutable: true` or use useTravel instead.' 230 | ); 231 | } 232 | const state = useSyncExternalStore( 233 | travels.subscribe, 234 | travels.getState, 235 | travels.getState 236 | ); 237 | const setState = useCallback( 238 | (updater: Updater) => travels.setState(updater), 239 | [travels] 240 | ); 241 | const controls = useMemo(() => travels.getControls(), [travels]); 242 | return [state as Value, setState, controls] as [ 243 | Value, 244 | (updater: Updater) => void, 245 | A extends false ? ManualTravelsControls : TravelsControls, 246 | ]; 247 | } 248 | -------------------------------------------------------------------------------- /test/validation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it, beforeEach, afterEach } from 'vitest'; 2 | import { renderHook } from '@testing-library/react'; 3 | import { vi } from 'vitest'; 4 | import { useTravel } from '../src/index'; 5 | 6 | // Mock __DEV__ flag to be true for these tests 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | globalThis.__DEV__ = true; 9 | 10 | describe('useTravel - Input Validation', () => { 11 | let consoleErrorSpy: ReturnType; 12 | 13 | beforeEach(() => { 14 | consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); 15 | }); 16 | 17 | afterEach(() => { 18 | consoleErrorSpy.mockRestore(); 19 | }); 20 | 21 | describe('maxHistory validation', () => { 22 | it('should log error when maxHistory is 0', () => { 23 | renderHook(() => 24 | useTravel(0, { 25 | maxHistory: 0, 26 | }) 27 | ); 28 | 29 | expect(consoleErrorSpy).toHaveBeenCalledWith( 30 | 'useTravel: maxHistory must be a positive number, but got 0' 31 | ); 32 | }); 33 | 34 | it('should log error when maxHistory is negative', () => { 35 | expect(() => 36 | renderHook(() => 37 | useTravel(0, { 38 | maxHistory: -5, 39 | }) 40 | ) 41 | ).toThrowError('Travels: maxHistory must be non-negative, but got -5'); 42 | 43 | expect(consoleErrorSpy).toHaveBeenCalledWith( 44 | 'useTravel: maxHistory must be a positive number, but got -5' 45 | ); 46 | }); 47 | 48 | it('should not log error when maxHistory is positive', () => { 49 | renderHook(() => 50 | useTravel(0, { 51 | maxHistory: 10, 52 | }) 53 | ); 54 | 55 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 56 | }); 57 | }); 58 | 59 | describe('initialPosition validation', () => { 60 | it('should log error when initialPosition is negative', () => { 61 | renderHook(() => 62 | useTravel(0, { 63 | initialPosition: -1, 64 | }) 65 | ); 66 | 67 | expect(consoleErrorSpy).toHaveBeenCalledWith( 68 | 'useTravel: initialPosition must be non-negative, but got -1' 69 | ); 70 | }); 71 | 72 | it('should log error when initialPosition is large negative number', () => { 73 | renderHook(() => 74 | useTravel(0, { 75 | initialPosition: -100, 76 | }) 77 | ); 78 | 79 | expect(consoleErrorSpy).toHaveBeenCalledWith( 80 | 'useTravel: initialPosition must be non-negative, but got -100' 81 | ); 82 | }); 83 | 84 | it('should not log error when initialPosition is 0', () => { 85 | renderHook(() => 86 | useTravel(0, { 87 | initialPosition: 0, 88 | }) 89 | ); 90 | 91 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should not log error when initialPosition is positive', () => { 95 | renderHook(() => 96 | useTravel(0, { 97 | initialPosition: 5, 98 | }) 99 | ); 100 | 101 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 102 | }); 103 | }); 104 | 105 | describe('initialPatches validation', () => { 106 | it('should log error when patches is not an array', () => { 107 | // This will throw an error when trying to use invalid patches 108 | // but should log the validation error first 109 | expect(() => { 110 | renderHook(() => 111 | useTravel(0, { 112 | initialPatches: { 113 | // @ts-expect-error - Testing invalid input 114 | patches: 'not-an-array', 115 | inversePatches: [], 116 | }, 117 | }) 118 | ); 119 | }).toThrow(); 120 | 121 | expect(consoleErrorSpy).toHaveBeenCalledWith( 122 | `useTravel: initialPatches must have 'patches' and 'inversePatches' arrays` 123 | ); 124 | }); 125 | 126 | it('should log error when inversePatches is not an array', () => { 127 | // This will throw an error when trying to use invalid patches 128 | // but should log the validation error first 129 | expect(() => { 130 | renderHook(() => 131 | useTravel(0, { 132 | initialPatches: { 133 | patches: [], 134 | // @ts-expect-error - Testing invalid input 135 | inversePatches: null, 136 | }, 137 | }) 138 | ); 139 | }).toThrow(); 140 | 141 | expect(consoleErrorSpy).toHaveBeenCalledWith( 142 | `useTravel: initialPatches must have 'patches' and 'inversePatches' arrays` 143 | ); 144 | }); 145 | 146 | it('should log error when both patches and inversePatches are not arrays', () => { 147 | // This will throw an error when trying to use invalid patches 148 | // but should log the validation error first 149 | expect(() => { 150 | renderHook(() => 151 | useTravel(0, { 152 | initialPatches: { 153 | // @ts-expect-error - Testing invalid input 154 | patches: {}, 155 | // @ts-expect-error - Testing invalid input 156 | inversePatches: {}, 157 | }, 158 | }) 159 | ); 160 | }).toThrow(); 161 | 162 | expect(consoleErrorSpy).toHaveBeenCalledWith( 163 | `useTravel: initialPatches must have 'patches' and 'inversePatches' arrays` 164 | ); 165 | }); 166 | 167 | it('should log error when patches and inversePatches have different lengths', () => { 168 | renderHook(() => 169 | useTravel(0, { 170 | initialPatches: { 171 | patches: [[{ op: 'replace', path: [], value: 1 }]], 172 | inversePatches: [ 173 | [{ op: 'replace', path: [], value: 0 }], 174 | [{ op: 'replace', path: [], value: -1 }], 175 | ], 176 | }, 177 | }) 178 | ); 179 | 180 | expect(consoleErrorSpy).toHaveBeenCalledWith( 181 | `useTravel: initialPatches.patches and initialPatches.inversePatches must have the same length` 182 | ); 183 | }); 184 | 185 | it('should not log error when initialPatches is valid', () => { 186 | renderHook(() => 187 | useTravel(0, { 188 | initialPatches: { 189 | patches: [[{ op: 'replace', path: [], value: 1 }]], 190 | inversePatches: [[{ op: 'replace', path: [], value: 0 }]], 191 | }, 192 | }) 193 | ); 194 | 195 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 196 | }); 197 | 198 | it('should not log error when initialPatches has empty arrays', () => { 199 | renderHook(() => 200 | useTravel(0, { 201 | initialPatches: { 202 | patches: [], 203 | inversePatches: [], 204 | }, 205 | }) 206 | ); 207 | 208 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 209 | }); 210 | 211 | it('should not log error when initialPatches is undefined', () => { 212 | renderHook(() => 213 | useTravel(0, { 214 | initialPatches: undefined, 215 | }) 216 | ); 217 | 218 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 219 | }); 220 | }); 221 | 222 | describe('multiple validation errors', () => { 223 | it('should log multiple errors when multiple options are invalid', () => { 224 | // This will throw an error when trying to use invalid patches 225 | // but should log all validation errors first 226 | expect(() => { 227 | renderHook(() => 228 | useTravel(0, { 229 | maxHistory: -1, 230 | initialPosition: -5, 231 | initialPatches: { 232 | // @ts-expect-error - Testing invalid input 233 | patches: 'invalid', 234 | inversePatches: [], 235 | }, 236 | }) 237 | ); 238 | }).toThrow(); 239 | 240 | // Check that all three validation errors were logged 241 | // (may be called more than 3 times due to React re-renders) 242 | expect(consoleErrorSpy).toHaveBeenCalledWith( 243 | 'useTravel: maxHistory must be a positive number, but got -1' 244 | ); 245 | expect(consoleErrorSpy).toHaveBeenCalledWith( 246 | 'useTravel: initialPosition must be non-negative, but got -5' 247 | ); 248 | expect(consoleErrorSpy).toHaveBeenCalledWith( 249 | `useTravel: initialPatches must have 'patches' and 'inversePatches' arrays` 250 | ); 251 | }); 252 | }); 253 | 254 | describe('valid options', () => { 255 | it('should not log any errors when all options are valid', () => { 256 | renderHook(() => 257 | useTravel( 258 | { count: 0 }, 259 | { 260 | maxHistory: 20, 261 | initialPosition: 0, 262 | initialPatches: { 263 | patches: [], 264 | inversePatches: [], 265 | }, 266 | autoArchive: true, 267 | } 268 | ) 269 | ); 270 | 271 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 272 | }); 273 | 274 | it('should not log any errors with default options', () => { 275 | renderHook(() => useTravel({ count: 0 })); 276 | 277 | expect(consoleErrorSpy).not.toHaveBeenCalled(); 278 | }); 279 | }); 280 | }); 281 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | **use-travel** • [**Docs**](globals.md) 2 | 3 | *** 4 | 5 | # use-travel 6 | 7 | ![Node CI](https://github.com/mutativejs/use-travel/workflows/Node%20CI/badge.svg) 8 | [![Coverage Status](https://coveralls.io/repos/github/mutativejs/use-travel/badge.svg?branch=main)](https://coveralls.io/github/mutativejs/use-travel?branch=main) 9 | [![npm](https://img.shields.io/npm/v/use-travel.svg)](https://www.npmjs.com/package/use-travel) 10 | ![license](https://img.shields.io/npm/l/use-travel) 11 | 12 | A React hook for state time travel with undo, redo, reset and archive functionalities. 13 | 14 | ### Motivation 15 | 16 | `use-travel` is a small and high-performance library for state time travel. It's built on [Mutative](https://github.com/unadlib/mutative) to support mutation updating immutable data. It's designed to be simple and easy to use, and it's also customizable for different use cases. 17 | 18 | It's suitable for building any time travel feature in your application. 19 | 20 | ### Installation 21 | 22 | ```bash 23 | npm install use-travel mutative 24 | # or 25 | yarn add use-travel mutative 26 | ``` 27 | 28 | ### Features 29 | 30 | - Undo/Redo/Reset/Go/Archive functionalities 31 | - Mutations update immutable data 32 | - Small size for time travel with JSON Patch history 33 | - Customizable history size 34 | - Customizable initial patches 35 | - High performance 36 | - Mark function for custom immutability 37 | 38 | ### Example 39 | 40 | - [Basic](https://stackblitz.com/edit/react-xfw3uk?file=src%2FApp.js) 41 | - [Manual Time Travel](https://stackblitz.com/edit/react-3mnzq9?file=src%2FApp.js) 42 | 43 | ### API 44 | 45 | You can use `useTravel` to create a time travel state. And it returns a tuple with the current state, the state setter, and the controls. The controls include `back()`, `forward()`, `reset()`, `canBack()`, `canForward()`, `canArchive()`, `getHistory()`, `patches`, `position`, `archive()`, and `go()`. 46 | 47 | ```jsx 48 | import { useTravel } from 'use-travel'; 49 | 50 | const App = () => { 51 | const [state, setState, controls] = useTravel(0, { 52 | maxHistory: 10, 53 | initialPatches: { 54 | patches: [], 55 | inversePatches: [], 56 | }, 57 | }); 58 | return ( 59 |
60 |
{state}
61 | 62 | 63 | 66 | 72 | 73 | {controls.getHistory().map((state, index) => ( 74 |
{state}
75 | ))} 76 | {controls.patches.patches.map((patch, index) => ( 77 |
{JSON.stringify(patch)}
78 | ))} 79 |
{controls.position}
80 | 87 |
88 | ); 89 | }; 90 | ``` 91 | 92 | ### Parameters 93 | 94 | | Parameter | type | description | default | 95 | | ------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------- | 96 | | `maxHistory` | number | The maximum number of history to keep | 10 | 97 | | `initialPatches` | TravelPatches | The initial patches | {patches: [],inversePatches: []} | 98 | | `initialPosition` | number | The initial position of the state | 0 | 99 | | `autoArchive` | boolean | Auto archive the state (see [Archive Mode](#archive-mode) for details) | true | 100 | | `enableAutoFreeze` | boolean | Enable auto freeze the state, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false | 101 | | `strict` | boolean | Enable strict mode, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false | 102 | | `mark` | Mark[] | The mark function , [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | () => void | 103 | 104 | ### Returns 105 | 106 | | Return | type | description | 107 | | --------------------- | ------------------------------ | ---------------------------------------------------------------------- | 108 | | `state` | Value | The current state | 109 | | `setState` | Updater> | The state setter, support mutation update or return immutable data | 110 | | `controls.back` | (amount?: number) => void | Go back to the previous state | 111 | | `controls.forward` | (amount?: number) => void | Go forward to the next state | 112 | | `controls.reset` | () => void | Reset the state to the initial state | 113 | | `controls.canBack` | () => boolean | Check if can go back to the previous state | 114 | | `controls.canForward` | () => boolean | Check if can go forward to the next state | 115 | | `controls.canArchive` | () => boolean | Check if can archive the current state | 116 | | `controls.getHistory` | () => T[] | Get the history of the state | 117 | | `controls.patches` | TravelPatches[] | Get the patches history of the state | 118 | | `controls.position` | number | Get the current position of the state | 119 | | `controls.go` | (nextPosition: number) => void | Go to the specific position of the state | 120 | | `controls.archive` | () => void | Archive the current state(the `autoArchive` options should be `false`) | 121 | 122 | ### Archive Mode 123 | 124 | `use-travel` provides two archive modes to control how state changes are recorded in history: 125 | 126 | #### Auto Archive Mode (default: `autoArchive: true`) 127 | 128 | In auto archive mode, every `setState` call is automatically recorded as a separate history entry. This is the simplest mode and suitable for most use cases. 129 | 130 | ```jsx 131 | const [state, setState, controls] = useTravel({ count: 0 }); 132 | // or explicitly: useTravel({ count: 0 }, { autoArchive: true }) 133 | 134 | // Each setState creates a new history entry 135 | setState({ count: 1 }); // History: [0, 1] 136 | // ... user clicks another button 137 | setState({ count: 2 }); // History: [0, 1, 2] 138 | // ... user clicks another button 139 | setState({ count: 3 }); // History: [0, 1, 2, 3] 140 | 141 | controls.back(); // Go back to count: 2 142 | ``` 143 | 144 | #### Manual Archive Mode (`autoArchive: false`) 145 | 146 | In manual archive mode, you control when state changes are recorded to history using the `archive()` function. This is useful when you want to group multiple state changes into a single undo/redo step. 147 | 148 | **Use Case 1: Batch multiple changes into one history entry** 149 | 150 | ```jsx 151 | const [state, setState, controls] = useTravel({ count: 0 }, { 152 | autoArchive: false 153 | }); 154 | 155 | // Multiple setState calls across different renders 156 | setState({ count: 1 }); // Temporary change (not in history yet) 157 | // ... user clicks another button 158 | setState({ count: 2 }); // Temporary change (not in history yet) 159 | // ... user clicks another button 160 | setState({ count: 3 }); // Temporary change (not in history yet) 161 | 162 | // Commit all changes as a single history entry 163 | controls.archive(); // History: [0, 3] 164 | 165 | // Now undo will go back to 0, not 2 or 1 166 | controls.back(); // Back to 0 167 | ``` 168 | 169 | **Use Case 2: Explicit commit after a single change** 170 | 171 | ```jsx 172 | function handleSave() { 173 | setState((draft) => { 174 | draft.count += 1; 175 | }); 176 | controls.archive(); // Commit immediately 177 | } 178 | ``` 179 | 180 | The key difference: 181 | - **Auto archive**: Each `setState` = one undo step 182 | - **Manual archive**: `archive()` call = one undo step (can include multiple `setState` calls) 183 | 184 | ### Important Notes 185 | 186 | > **⚠️ setState Restriction**: `setState` can only be called **once** within the same synchronous call stack (e.g., inside a single event handler). This ensures predictable undo/redo behavior where each history entry represents a clear, atomic change. 187 | 188 | ```jsx 189 | const App = () => { 190 | const [state, setState, controls] = useTravel({ count: 0, todo: [] }); 191 | return ( 192 |
193 |
{state.count}
194 | 214 |
215 | ); 216 | }; 217 | ``` 218 | 219 | > **Note**: With `autoArchive: false`, you can call `setState` once per event handler across multiple renders, then call `archive()` whenever you want to commit those changes to history. 220 | 221 | ### Persistence 222 | 223 | > `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`. 224 | 225 | > If you want to persist the state, you can use `state`/`controls.patches`/`controls.position` to save the travel history. Then, read the persistent data as `initialState`, `initialPatches`, and `initialPosition` when initializing the state, like this: 226 | 227 | ```jsx 228 | const [state, setState, controls] = useTravel(initialState, { 229 | initialPatches, 230 | initialPosition, 231 | }); 232 | ``` 233 | 234 | ## License 235 | 236 | `use-travel` is [MIT licensed](https://github.com/mutativejs/use-travel/blob/main/LICENSE). 237 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # use-travel 2 | 3 | ![Node CI](https://github.com/mutativejs/use-travel/workflows/Node%20CI/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/mutativejs/use-travel/badge.svg?branch=main)](https://coveralls.io/github/mutativejs/use-travel?branch=main) 5 | [![npm](https://img.shields.io/npm/v/use-travel.svg)](https://www.npmjs.com/package/use-travel) 6 | ![license](https://img.shields.io/npm/l/use-travel) 7 | 8 | A React hook for state time travel with undo, redo, reset and archive functionalities with [Travels](https://github.com/mutativejs/travels). 9 | 10 | ### Motivation 11 | 12 | `use-travel` is a small and high-performance library for state time travel. It's built on [Mutative](https://github.com/unadlib/mutative) and [Travels](https://github.com/mutativejs/travels) to support mutation updating immutable data. It's designed to be simple and easy to use, and it's also customizable for different use cases. 13 | 14 | It's suitable for building any time travel feature in your application. 15 | 16 | ### Installation 17 | 18 | ```bash 19 | npm install use-travel mutative travels 20 | # or 21 | yarn add use-travel mutative travels 22 | # or 23 | pnpm add use-travel mutative travels 24 | ``` 25 | 26 | ### Features 27 | 28 | - Undo/Redo/Reset/Go/Archive functionalities 29 | - Mutations update immutable data 30 | - Small size for time travel with JSON Patch history 31 | - Customizable history size 32 | - Customizable initial patches 33 | - High performance 34 | - Mark function for custom immutability 35 | 36 | ### Example 37 | 38 | - [Basic](https://stackblitz.com/edit/react-xfw3uk?file=src%2FApp.js) 39 | - [Manual Time Travel](https://stackblitz.com/edit/react-3mnzq9?file=src%2FApp.js) 40 | 41 | ### API 42 | 43 | You can use `useTravel` to create a time travel state. And it returns a tuple with the current state, the state setter, and the controls. The controls include `back()`, `forward()`, `reset()`, `canBack()`, `canForward()`, `canArchive()`, `getHistory()`, `patches`, `position`, `archive()`, and `go()`. 44 | 45 | ```jsx 46 | import { useTravel } from 'use-travel'; 47 | 48 | const App = () => { 49 | const [state, setState, controls] = useTravel(0, { 50 | maxHistory: 10, 51 | initialPatches: { 52 | patches: [], 53 | inversePatches: [], 54 | }, 55 | }); 56 | return ( 57 |
58 |
{state}
59 | 60 | 61 | 64 | 70 | 71 | {controls.getHistory().map((state, index) => ( 72 |
{state}
73 | ))} 74 | {controls.patches.patches.map((patch, index) => ( 75 |
{JSON.stringify(patch)}
76 | ))} 77 |
{controls.position}
78 | 85 |
86 | ); 87 | }; 88 | ``` 89 | 90 | ### Parameters 91 | 92 | | Parameter | type | description | default | 93 | | ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | 94 | | `maxHistory` | `number` | The maximum number of history to keep | 10 | 95 | | `initialPatches` | `TravelPatches` | The initial patches | {patches: [],inversePatches: []} | 96 | | `initialPosition` | `number` | The initial position of the state | 0 | 97 | | `autoArchive` | `boolean` | Auto archive the state (see [Archive Mode](#archive-mode) for details) | true | 98 | | `enableAutoFreeze` | `boolean` | Enable auto freeze the state, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false | 99 | | `strict` | `boolean` | Enable strict mode, [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | false | 100 | | `mark` | `Mark[]` | The mark function , [view more](https://github.com/unadlib/mutative?tab=readme-ov-file#createstate-fn-options) | () => void | 101 | | `patchesOptions` | `boolean | PatchesOptions` | Customize JSON Patch format. Supports `{ pathAsArray: boolean }` to control path format. See [Mutative patches docs](https://mutative.js.org/docs/api-reference/create#patches) | `true` (enable patches) | 102 | 103 | ### Returns 104 | 105 | | Return | type | description | 106 | | --------------------- | ------------------------------ | ---------------------------------------------------------------------- | 107 | | `state` | Value | The current state | 108 | | `setState` | Updater> | The state setter, support mutation update or return immutable data | 109 | | `controls.back` | (amount?: number) => void | Go back to the previous state | 110 | | `controls.forward` | (amount?: number) => void | Go forward to the next state | 111 | | `controls.reset` | () => void | Reset the state to the initial state | 112 | | `controls.canBack` | () => boolean | Check if can go back to the previous state | 113 | | `controls.canForward` | () => boolean | Check if can go forward to the next state | 114 | | `controls.canArchive` | () => boolean | Check if can archive the current state | 115 | | `controls.getHistory` | () => T[] | Get the history of the state | 116 | | `controls.patches` | TravelPatches[] | Get the patches history of the state | 117 | | `controls.position` | number | Get the current position of the state | 118 | | `controls.go` | (nextPosition: number) => void | Go to the specific position of the state | 119 | | `controls.archive` | () => void | Archive the current state(the `autoArchive` options should be `false`) | 120 | 121 | ### useTravelStore 122 | 123 | When you need to manage a single `Travels` instance outside of React—e.g. to share the same undo/redo history across multiple components—create the store manually and bind it with `useTravelStore`. The hook keeps React in sync with the external store, exposes the same controls object, and rejects mutable stores to ensure React can observe updates. 124 | 125 | ```tsx 126 | // store.ts 127 | import { Travels } from 'travels'; 128 | 129 | export const travels = new Travels({ count: 0 }); // mutable: true is not supported 130 | ``` 131 | 132 | ```tsx 133 | // Counter.tsx 134 | import { useTravelStore } from 'use-travel'; 135 | import { travels } from './store'; 136 | 137 | export function Counter() { 138 | const [state, setState, controls] = useTravelStore(travels); 139 | 140 | return ( 141 |
142 | {state.count} 143 | 152 | 155 |
156 | ); 157 | } 158 | ``` 159 | 160 | `useTravelStore` stays reactive even when the `Travels` instance is updated elsewhere (for example, in services or other components) and forwards manual archive helpers when the store is created with `autoArchive: false`. 161 | 162 | ### Archive Mode 163 | 164 | `use-travel` provides two archive modes to control how state changes are recorded in history: 165 | 166 | #### Auto Archive Mode (default: `autoArchive: true`) 167 | 168 | In auto archive mode, every `setState` call is automatically recorded as a separate history entry. This is the simplest mode and suitable for most use cases. 169 | 170 | ```jsx 171 | const [state, setState, controls] = useTravel({ count: 0 }); 172 | // or explicitly: useTravel({ count: 0 }, { autoArchive: true }) 173 | 174 | // Each setState creates a new history entry 175 | setState({ count: 1 }); // History: [0, 1] 176 | // ... user clicks another button 177 | setState({ count: 2 }); // History: [0, 1, 2] 178 | // ... user clicks another button 179 | setState({ count: 3 }); // History: [0, 1, 2, 3] 180 | 181 | controls.back(); // Go back to count: 2 182 | ``` 183 | 184 | #### Manual Archive Mode (`autoArchive: false`) 185 | 186 | In manual archive mode, you control when state changes are recorded to history using the `archive()` function. This is useful when you want to group multiple state changes into a single undo/redo step. 187 | 188 | **Use Case 1: Batch multiple changes into one history entry** 189 | 190 | ```jsx 191 | const [state, setState, controls] = useTravel( 192 | { count: 0 }, 193 | { 194 | autoArchive: false, 195 | } 196 | ); 197 | 198 | // Multiple setState calls across different renders 199 | setState({ count: 1 }); // Temporary change (not in history yet) 200 | // ... user clicks another button 201 | setState({ count: 2 }); // Temporary change (not in history yet) 202 | // ... user clicks another button 203 | setState({ count: 3 }); // Temporary change (not in history yet) 204 | 205 | // Commit all changes as a single history entry 206 | controls.archive(); // History: [0, 3] 207 | 208 | // Now undo will go back to 0, not 2 or 1 209 | controls.back(); // Back to 0 210 | ``` 211 | 212 | **Use Case 2: Explicit commit after a single change** 213 | 214 | ```jsx 215 | function handleSave() { 216 | setState((draft) => { 217 | draft.count += 1; 218 | }); 219 | controls.archive(); // Commit immediately 220 | } 221 | ``` 222 | 223 | The key difference: 224 | 225 | - **Auto archive**: Each `setState` = one undo step 226 | - **Manual archive**: `archive()` call = one undo step (can include multiple `setState` calls) 227 | 228 | ### Important Notes 229 | 230 | > **⚠️ setState Restriction**: `setState` can only be called **once** within the same synchronous call stack (e.g., inside a single event handler). This ensures predictable undo/redo behavior where each history entry represents a clear, atomic change. 231 | 232 | ```jsx 233 | const App = () => { 234 | const [state, setState, controls] = useTravel({ count: 0, todo: [] }); 235 | return ( 236 |
237 |
{state.count}
238 | 258 |
259 | ); 260 | }; 261 | ``` 262 | 263 | > **Note**: With `autoArchive: false`, you can call `setState` once per event handler across multiple renders, then call `archive()` whenever you want to commit those changes to history. 264 | 265 | ### Persistence 266 | 267 | > `TravelPatches` is the type of patches history, it includes `patches` and `inversePatches`. 268 | 269 | > If you want to persist the state, you can use `state`/`controls.patches`/`controls.position` to save the travel history. Then, read the persistent data as `initialState`, `initialPatches`, and `initialPosition` when initializing the state, like this: 270 | 271 | ```jsx 272 | const [state, setState, controls] = useTravel(initialState, { 273 | initialPatches, 274 | initialPosition, 275 | }); 276 | ``` 277 | 278 | ## License 279 | 280 | `use-travel` is [MIT licensed](https://github.com/mutativejs/use-travel/blob/main/LICENSE). 281 | -------------------------------------------------------------------------------- /test/edge-cases.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest'; 2 | import { act, renderHook } from '@testing-library/react'; 3 | import { useTravel } from '../src/index'; 4 | 5 | describe('useTravel - Edge Cases', () => { 6 | describe('canForward and canBack edge cases', () => { 7 | it('should correctly report canForward/canBack with autoArchive: true at boundaries', () => { 8 | const { result } = renderHook(() => 9 | useTravel(0, { autoArchive: true, maxHistory: 10 }) 10 | ); 11 | 12 | let [state, setState, controls] = result.current; 13 | 14 | // Initial state: position = 0, no history yet 15 | expect(controls.position).toBe(0); 16 | expect(controls.canBack()).toBe(false); 17 | expect(controls.canForward()).toBe(false); 18 | 19 | // Add first state 20 | act(() => setState(1)); 21 | [state, setState, controls] = result.current; 22 | expect(controls.position).toBe(1); 23 | expect(controls.canBack()).toBe(true); 24 | expect(controls.canForward()).toBe(false); 25 | 26 | // Add second state 27 | act(() => setState(2)); 28 | [state, setState, controls] = result.current; 29 | expect(controls.position).toBe(2); 30 | expect(controls.canBack()).toBe(true); 31 | expect(controls.canForward()).toBe(false); 32 | 33 | // Go back to middle 34 | act(() => controls.back()); 35 | [state, setState, controls] = result.current; 36 | expect(controls.position).toBe(1); 37 | expect(controls.canBack()).toBe(true); 38 | expect(controls.canForward()).toBe(true); 39 | 40 | // Go back to start 41 | act(() => controls.back()); 42 | [state, setState, controls] = result.current; 43 | expect(controls.position).toBe(0); 44 | expect(controls.canBack()).toBe(false); 45 | expect(controls.canForward()).toBe(true); 46 | 47 | // Go forward to end 48 | act(() => controls.forward(2)); 49 | [state, setState, controls] = result.current; 50 | expect(controls.position).toBe(2); 51 | expect(controls.canBack()).toBe(true); 52 | expect(controls.canForward()).toBe(false); 53 | }); 54 | 55 | it('should correctly report canForward/canBack with autoArchive: false without temporary patches', () => { 56 | const { result } = renderHook(() => 57 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 58 | ); 59 | 60 | let [state, setState, controls] = result.current; 61 | 62 | // Initial state 63 | expect(controls.position).toBe(0); 64 | expect(controls.canBack()).toBe(false); 65 | expect(controls.canForward()).toBe(false); 66 | expect(controls.canArchive()).toBe(false); 67 | 68 | // Add and archive first state 69 | act(() => { 70 | setState(1); 71 | controls.archive(); 72 | }); 73 | [state, setState, controls] = result.current; 74 | expect(controls.position).toBe(1); 75 | expect(controls.canBack()).toBe(true); 76 | expect(controls.canForward()).toBe(false); 77 | expect(controls.canArchive()).toBe(false); 78 | 79 | // Add and archive second state 80 | act(() => { 81 | setState(2); 82 | controls.archive(); 83 | }); 84 | [state, setState, controls] = result.current; 85 | expect(controls.position).toBe(2); 86 | expect(controls.canBack()).toBe(true); 87 | expect(controls.canForward()).toBe(false); 88 | expect(controls.canArchive()).toBe(false); 89 | 90 | // Go back to middle 91 | act(() => controls.back()); 92 | [state, setState, controls] = result.current; 93 | expect(controls.position).toBe(1); 94 | expect(controls.canBack()).toBe(true); 95 | expect(controls.canForward()).toBe(true); 96 | 97 | // Go back to start 98 | act(() => controls.back()); 99 | [state, setState, controls] = result.current; 100 | expect(controls.position).toBe(0); 101 | expect(controls.canBack()).toBe(false); 102 | expect(controls.canForward()).toBe(true); 103 | }); 104 | 105 | it('should correctly report canForward/canBack with temporary patches at end position', () => { 106 | const { result } = renderHook(() => 107 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 108 | ); 109 | 110 | let [state, setState, controls] = result.current; 111 | 112 | // Build archived history 113 | act(() => { 114 | setState(1); 115 | controls.archive(); 116 | }); 117 | [state, setState, controls] = result.current; 118 | 119 | act(() => { 120 | setState(2); 121 | controls.archive(); 122 | }); 123 | [state, setState, controls] = result.current; 124 | 125 | // Position = 2, patches.length = 2 126 | expect(controls.position).toBe(2); 127 | expect(controls.patches.patches.length).toBe(2); 128 | expect(controls.canBack()).toBe(true); 129 | expect(controls.canForward()).toBe(false); 130 | expect(controls.canArchive()).toBe(false); 131 | 132 | // Add temporary patch (not archived) 133 | act(() => setState(3)); 134 | [state, setState, controls] = result.current; 135 | 136 | // When adding temp patch at end, position increments to 3 137 | // but patches are not yet archived 138 | expect(controls.position).toBe(3); 139 | expect(controls.patches.patches.length).toBe(3); // includes temp 140 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); // shows all history including temp 141 | expect(controls.canBack()).toBe(true); 142 | expect(controls.canForward()).toBe(false); // Can't forward from temp state 143 | expect(controls.canArchive()).toBe(true); 144 | 145 | // Verify getHistory shows the temporary state 146 | const history = controls.getHistory(); 147 | expect(history.length).toBe(4); 148 | expect(history[3]).toBe(3); // Temporary state is visible 149 | }); 150 | 151 | it('should correctly report canForward/canBack with temporary patches at middle position', () => { 152 | const { result } = renderHook(() => 153 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 154 | ); 155 | 156 | let [state, setState, controls] = result.current; 157 | 158 | // Build archived history 159 | act(() => { 160 | setState(1); 161 | controls.archive(); 162 | }); 163 | [state, setState, controls] = result.current; 164 | 165 | act(() => { 166 | setState(2); 167 | controls.archive(); 168 | }); 169 | [state, setState, controls] = result.current; 170 | 171 | act(() => { 172 | setState(3); 173 | controls.archive(); 174 | }); 175 | [state, setState, controls] = result.current; 176 | 177 | // Go back to middle 178 | act(() => controls.back()); 179 | [state, setState, controls] = result.current; 180 | expect(controls.position).toBe(2); 181 | expect(state).toBe(2); 182 | 183 | // Add temporary patch from middle position (notLast = true, so position increments) 184 | act(() => setState(10)); 185 | [state, setState, controls] = result.current; 186 | 187 | // Position increments because we were in the middle (notLast) 188 | expect(controls.position).toBe(3); 189 | expect(state).toBe(10); 190 | expect(controls.patches.patches.length).toBe(3); // 2 archived + 1 temp 191 | expect(controls.getHistory()).toEqual([0, 1, 2, 10]); // temp state replaces position 3 onward 192 | expect(controls.canBack()).toBe(true); 193 | expect(controls.canForward()).toBe(false); // Can't forward, temp is the new end 194 | expect(controls.canArchive()).toBe(true); 195 | }); 196 | 197 | it('should handle canForward after navigating back with temporary patches', () => { 198 | const { result } = renderHook(() => 199 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 200 | ); 201 | 202 | let [state, setState, controls] = result.current; 203 | 204 | // Build history with temp patch 205 | act(() => { 206 | setState(1); 207 | controls.archive(); 208 | }); 209 | [state, setState, controls] = result.current; 210 | 211 | act(() => { 212 | setState(2); 213 | controls.archive(); 214 | }); 215 | [state, setState, controls] = result.current; 216 | 217 | act(() => setState(3)); // Temporary - position increments to 3 218 | [state, setState, controls] = result.current; 219 | 220 | expect(controls.position).toBe(3); 221 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); 222 | expect(controls.canForward()).toBe(false); 223 | 224 | // Go back - this should archive the temp patch first 225 | act(() => controls.back()); 226 | [state, setState, controls] = result.current; 227 | 228 | // After back(), temp patch was archived and we're at position 2 (state 2) 229 | expect(controls.position).toBe(2); 230 | expect(state).toBe(2); 231 | expect(controls.canArchive()).toBe(false); // Temp was archived 232 | expect(controls.canForward()).toBe(true); // Can now forward to archived temp state 233 | expect(controls.patches.patches.length).toBe(3); 234 | 235 | // Forward should go to the archived temp state 236 | act(() => controls.forward()); 237 | [state, setState, controls] = result.current; 238 | expect(state).toBe(3); 239 | expect(controls.position).toBe(3); 240 | expect(controls.canForward()).toBe(false); 241 | }); 242 | 243 | it('should handle boundary conditions with maxHistory limit', () => { 244 | const { result } = renderHook(() => 245 | useTravel(0, { autoArchive: false, maxHistory: 3 }) 246 | ); 247 | 248 | let [state, setState, controls] = result.current; 249 | 250 | // Fill up to maxHistory 251 | for (let i = 1; i <= 4; i++) { 252 | act(() => { 253 | setState(i); 254 | controls.archive(); 255 | }); 256 | [state, setState, controls] = result.current; 257 | } 258 | 259 | // Position should be capped at maxHistory 260 | expect(controls.position).toBe(3); 261 | expect(controls.patches.patches.length).toBe(3); 262 | expect(controls.getHistory().length).toBe(4); 263 | expect(controls.canBack()).toBe(true); 264 | expect(controls.canForward()).toBe(false); 265 | 266 | // Add temporary patch 267 | act(() => setState(5)); 268 | [state, setState, controls] = result.current; 269 | 270 | expect(controls.position).toBe(3); // Capped by maxHistory 271 | expect(controls.patches.patches.length).toBe(4); // includes temp 272 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 273 | expect(controls.canBack()).toBe(true); 274 | expect(controls.canForward()).toBe(false); 275 | expect(controls.canArchive()).toBe(true); 276 | }); 277 | 278 | it('should correctly handle canForward when temporary patch is at position 0', () => { 279 | const { result } = renderHook(() => 280 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 281 | ); 282 | 283 | let [state, setState, controls] = result.current; 284 | 285 | // Initial state with immediate temp patch 286 | act(() => setState(1)); 287 | [state, setState, controls] = result.current; 288 | 289 | expect(controls.position).toBe(1); 290 | expect(state).toBe(1); 291 | expect(controls.patches.patches.length).toBe(1); 292 | expect(controls.getHistory()).toEqual([0, 1]); 293 | expect(controls.canBack()).toBe(true); 294 | expect(controls.canForward()).toBe(false); 295 | expect(controls.canArchive()).toBe(true); 296 | 297 | // Go back to initial state 298 | act(() => controls.back()); 299 | [state, setState, controls] = result.current; 300 | 301 | // Temp patch should be archived on back() 302 | expect(controls.position).toBe(0); 303 | expect(state).toBe(0); 304 | expect(controls.canBack()).toBe(false); 305 | expect(controls.canForward()).toBe(true); 306 | expect(controls.canArchive()).toBe(false); 307 | }); 308 | }); 309 | 310 | describe('go() function boundary behavior', () => { 311 | it('should clamp position when going beyond bounds with autoArchive: true', () => { 312 | const { result } = renderHook(() => 313 | useTravel(0, { autoArchive: true, maxHistory: 10 }) 314 | ); 315 | 316 | let [state, setState, controls] = result.current; 317 | 318 | act(() => setState(1)); 319 | [state, setState, controls] = result.current; 320 | act(() => setState(2)); 321 | [state, setState, controls] = result.current; 322 | 323 | // With autoArchive, patches.length = 2, valid positions are 0,1,2 324 | expect(controls.position).toBe(2); 325 | expect(controls.patches.patches.length).toBe(2); 326 | 327 | // Try to go beyond forward bound 328 | const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 329 | 330 | act(() => controls.go(10)); 331 | [state, setState, controls] = result.current; 332 | 333 | expect(warnSpy).toHaveBeenCalledWith("Can't go forward to position 10"); 334 | expect(controls.position).toBe(2); // Clamped to patches.length 335 | expect(state).toBe(2); 336 | 337 | // Try to go beyond backward bound 338 | act(() => controls.go(-5)); 339 | [state, setState, controls] = result.current; 340 | 341 | expect(warnSpy).toHaveBeenCalledWith("Can't go back to position -5"); 342 | expect(controls.position).toBe(0); // Clamped to min 343 | expect(state).toBe(0); 344 | 345 | warnSpy.mockRestore(); 346 | }); 347 | 348 | it('should clamp position when going beyond bounds with autoArchive: false and temp patches', () => { 349 | const { result } = renderHook(() => 350 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 351 | ); 352 | 353 | let [state, setState, controls] = result.current; 354 | 355 | act(() => { 356 | setState(1); 357 | controls.archive(); 358 | }); 359 | [state, setState, controls] = result.current; 360 | act(() => { 361 | setState(2); 362 | controls.archive(); 363 | }); 364 | [state, setState, controls] = result.current; 365 | act(() => setState(3)); // Temporary - position moves to 3 366 | [state, setState, controls] = result.current; 367 | 368 | expect(controls.position).toBe(3); 369 | expect(controls.patches.patches.length).toBe(3); 370 | 371 | const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 372 | 373 | // Try to go beyond forward bound (should archive first, then clamp) 374 | act(() => controls.go(10)); 375 | [state, setState, controls] = result.current; 376 | 377 | expect(warnSpy).toHaveBeenCalledWith("Can't go forward to position 10"); 378 | expect(controls.position).toBe(3); // Clamped to max (after archiving) 379 | expect(state).toBe(3); 380 | expect(controls.canArchive()).toBe(false); // Temp was archived 381 | 382 | warnSpy.mockRestore(); 383 | }); 384 | 385 | it('should handle go(currentPosition) as no-op', () => { 386 | const { result } = renderHook(() => 387 | useTravel(0, { autoArchive: true, maxHistory: 10 }) 388 | ); 389 | 390 | let [state, setState, controls] = result.current; 391 | 392 | act(() => setState(1)); 393 | act(() => setState(2)); 394 | [state, setState, controls] = result.current; 395 | 396 | const currentPosition = controls.position; 397 | const currentState = state; 398 | 399 | // Go to current position should be no-op 400 | act(() => controls.go(currentPosition)); 401 | [state, setState, controls] = result.current; 402 | 403 | expect(controls.position).toBe(currentPosition); 404 | expect(state).toBe(currentState); 405 | }); 406 | }); 407 | 408 | describe('Complex navigation scenarios', () => { 409 | it('should handle: back -> setState -> back -> setState -> archive sequence', () => { 410 | const { result } = renderHook(() => 411 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 412 | ); 413 | 414 | let [state, setState, controls] = result.current; 415 | 416 | // Build initial history 417 | act(() => { 418 | setState(1); 419 | controls.archive(); 420 | }); 421 | [state, setState, controls] = result.current; 422 | act(() => { 423 | setState(2); 424 | controls.archive(); 425 | }); 426 | [state, setState, controls] = result.current; 427 | act(() => { 428 | setState(3); 429 | controls.archive(); 430 | }); 431 | [state, setState, controls] = result.current; 432 | 433 | expect(controls.position).toBe(3); 434 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); 435 | 436 | // Go back and add temp state 437 | act(() => controls.back()); 438 | [state, setState, controls] = result.current; 439 | expect(controls.position).toBe(2); 440 | 441 | act(() => setState(10)); 442 | [state, setState, controls] = result.current; 443 | expect(controls.position).toBe(3); // Position increments because notLast 444 | expect(state).toBe(10); 445 | expect(controls.getHistory()).toEqual([0, 1, 2, 10]); 446 | expect(controls.canArchive()).toBe(true); 447 | 448 | // Go back again (should archive temp first) 449 | act(() => controls.back()); 450 | [state, setState, controls] = result.current; 451 | expect(controls.position).toBe(2); 452 | expect(state).toBe(2); 453 | expect(controls.canArchive()).toBe(false); 454 | 455 | // Add another temp state 456 | act(() => setState(20)); 457 | [state, setState, controls] = result.current; 458 | expect(controls.position).toBe(3); // Position increments again 459 | expect(state).toBe(20); 460 | expect(controls.canArchive()).toBe(true); 461 | 462 | // Archive manually 463 | act(() => controls.archive()); 464 | [state, setState, controls] = result.current; 465 | expect(controls.position).toBe(3); 466 | expect(state).toBe(20); 467 | // When we went back from 10, it archived. Then we added 20 from position 2, 468 | // which replaced the history at position 3 469 | expect(controls.getHistory()).toEqual([0, 1, 2, 20]); 470 | expect(controls.canArchive()).toBe(false); 471 | }); 472 | 473 | it('should handle rapid state changes without archive then navigate', () => { 474 | const { result } = renderHook(() => 475 | useTravel(0, { autoArchive: false, maxHistory: 10 }) 476 | ); 477 | 478 | let [state, setState, controls] = result.current; 479 | 480 | // Multiple setState without archive (each overwrites temp) 481 | act(() => setState(1)); 482 | [state, setState, controls] = result.current; 483 | expect(state).toBe(1); 484 | 485 | act(() => setState(2)); 486 | [state, setState, controls] = result.current; 487 | expect(state).toBe(2); 488 | 489 | act(() => setState(3)); 490 | [state, setState, controls] = result.current; 491 | expect(state).toBe(3); 492 | 493 | // Only the last temp state should be in history 494 | expect(controls.position).toBe(1); 495 | expect(controls.getHistory()).toEqual([0, 3]); 496 | expect(controls.canArchive()).toBe(true); 497 | 498 | // Navigate back should archive the temp 499 | act(() => controls.back()); 500 | [state, setState, controls] = result.current; 501 | expect(state).toBe(0); 502 | expect(controls.position).toBe(0); 503 | expect(controls.canArchive()).toBe(false); 504 | 505 | // Forward should go to archived temp 506 | act(() => controls.forward()); 507 | [state, setState, controls] = result.current; 508 | expect(state).toBe(3); 509 | expect(controls.position).toBe(1); 510 | }); 511 | }); 512 | }); 513 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, describe, it } from 'vitest'; 2 | import { act, renderHook } from '@testing-library/react'; 3 | import { vi } from 'vitest'; 4 | import { useTravel } from '../src/index'; 5 | 6 | describe('useTravel', () => { 7 | it('[useTravel] with normal init state', () => { 8 | const { result } = renderHook(() => 9 | useTravel({ todos: [] } as { todos: { name: string }[] }) 10 | ); 11 | let [nextState, setState, controls] = result.current; 12 | expect(nextState).toEqual({ todos: [] }); 13 | expect(controls.getHistory()).toEqual([{ todos: [] }]); 14 | 15 | act(() => 16 | setState({ 17 | todos: [ 18 | { 19 | name: 'todo 1', 20 | }, 21 | { 22 | name: 'todo 2', 23 | }, 24 | ], 25 | }) 26 | ); 27 | [nextState, setState, controls] = result.current; 28 | expect(nextState).toEqual({ 29 | todos: [ 30 | { 31 | name: 'todo 1', 32 | }, 33 | { 34 | name: 'todo 2', 35 | }, 36 | ], 37 | }); 38 | expect(controls.getHistory()).toEqual([ 39 | { todos: [] }, 40 | { 41 | todos: [ 42 | { 43 | name: 'todo 1', 44 | }, 45 | { 46 | name: 'todo 2', 47 | }, 48 | ], 49 | }, 50 | ]); 51 | 52 | act(() => 53 | setState((draft) => { 54 | draft.todos.push({ 55 | name: 'todo 3', 56 | }); 57 | }) 58 | ); 59 | [nextState, setState, controls] = result.current; 60 | 61 | act(() => controls.back()); 62 | [nextState, setState, controls] = result.current; 63 | 64 | expect(nextState).toEqual({ 65 | todos: [ 66 | { 67 | name: 'todo 1', 68 | }, 69 | { 70 | name: 'todo 2', 71 | }, 72 | ], 73 | }); 74 | 75 | act(() => controls.forward()); 76 | [nextState, setState, controls] = result.current; 77 | 78 | expect(nextState).toEqual({ 79 | todos: [ 80 | { 81 | name: 'todo 1', 82 | }, 83 | { 84 | name: 'todo 2', 85 | }, 86 | { 87 | name: 'todo 3', 88 | }, 89 | ], 90 | }); 91 | 92 | act(() => controls.go(0)); 93 | [nextState, setState, controls] = result.current; 94 | 95 | expect(nextState).toEqual({ 96 | todos: [], 97 | }); 98 | 99 | act(() => controls.go(1)); 100 | [nextState, setState, controls] = result.current; 101 | 102 | expect(nextState).toEqual({ 103 | todos: [ 104 | { 105 | name: 'todo 1', 106 | }, 107 | { 108 | name: 'todo 2', 109 | }, 110 | ], 111 | }); 112 | 113 | expect(controls.getHistory()).toEqual([ 114 | { 115 | todos: [], 116 | }, 117 | { 118 | todos: [ 119 | { 120 | name: 'todo 1', 121 | }, 122 | { 123 | name: 'todo 2', 124 | }, 125 | ], 126 | }, 127 | { 128 | todos: [ 129 | { 130 | name: 'todo 1', 131 | }, 132 | { 133 | name: 'todo 2', 134 | }, 135 | { 136 | name: 'todo 3', 137 | }, 138 | ], 139 | }, 140 | ]); 141 | 142 | act(() => 143 | setState((draft) => { 144 | draft.todos.push({ 145 | name: 'todo 4', 146 | }); 147 | }) 148 | ); 149 | [nextState, setState, controls] = result.current; 150 | 151 | act(() => 152 | setState((draft) => { 153 | draft.todos.push({ 154 | name: 'todo 5', 155 | }); 156 | }) 157 | ); 158 | [nextState, setState, controls] = result.current; 159 | 160 | expect(nextState).toEqual({ 161 | todos: [ 162 | { 163 | name: 'todo 1', 164 | }, 165 | { 166 | name: 'todo 2', 167 | }, 168 | { 169 | name: 'todo 4', 170 | }, 171 | { 172 | name: 'todo 5', 173 | }, 174 | ], 175 | }); 176 | 177 | expect(controls.getHistory()).toEqual([ 178 | { 179 | todos: [], 180 | }, 181 | { 182 | todos: [ 183 | { 184 | name: 'todo 1', 185 | }, 186 | { 187 | name: 'todo 2', 188 | }, 189 | ], 190 | }, 191 | { 192 | todos: [ 193 | { 194 | name: 'todo 1', 195 | }, 196 | { 197 | name: 'todo 2', 198 | }, 199 | { 200 | name: 'todo 4', 201 | }, 202 | ], 203 | }, 204 | { 205 | todos: [ 206 | { 207 | name: 'todo 1', 208 | }, 209 | { 210 | name: 'todo 2', 211 | }, 212 | { 213 | name: 'todo 4', 214 | }, 215 | { 216 | name: 'todo 5', 217 | }, 218 | ], 219 | }, 220 | ]); 221 | 222 | act(() => controls.go(1)); 223 | [nextState, setState, controls] = result.current; 224 | 225 | expect(controls.getHistory()).toEqual([ 226 | { 227 | todos: [], 228 | }, 229 | { 230 | todos: [ 231 | { 232 | name: 'todo 1', 233 | }, 234 | { 235 | name: 'todo 2', 236 | }, 237 | ], 238 | }, 239 | { 240 | todos: [ 241 | { 242 | name: 'todo 1', 243 | }, 244 | { 245 | name: 'todo 2', 246 | }, 247 | { 248 | name: 'todo 4', 249 | }, 250 | ], 251 | }, 252 | { 253 | todos: [ 254 | { 255 | name: 'todo 1', 256 | }, 257 | { 258 | name: 'todo 2', 259 | }, 260 | { 261 | name: 'todo 4', 262 | }, 263 | { 264 | name: 'todo 5', 265 | }, 266 | ], 267 | }, 268 | ]); 269 | 270 | act(() => controls.reset()); 271 | [nextState, setState, controls] = result.current; 272 | expect(nextState).toEqual({ todos: [] }); 273 | expect(controls.getHistory()).toEqual([{ todos: [] }]); 274 | }); 275 | 276 | it('[useTravel] with normal init state', () => { 277 | const { result } = renderHook(() => 278 | useTravel({ todos: [] } as { todos: { name: string }[] }) 279 | ); 280 | let [nextState, setState, controls] = result.current; 281 | expect(nextState).toEqual({ todos: [] }); 282 | act(() => 283 | setState((draft) => { 284 | draft.todos.push({ 285 | name: 'todo 1', 286 | }); 287 | draft.todos.push({ 288 | name: 'todo 2', 289 | }); 290 | }) 291 | ); 292 | [nextState, setState, controls] = result.current; 293 | expect(nextState).toEqual({ 294 | todos: [ 295 | { 296 | name: 'todo 1', 297 | }, 298 | { 299 | name: 'todo 2', 300 | }, 301 | ], 302 | }); 303 | 304 | act(() => 305 | setState((draft) => { 306 | draft.todos.push({ 307 | name: 'todo 3', 308 | }); 309 | }) 310 | ); 311 | [nextState, setState, controls] = result.current; 312 | act(() => 313 | setState((draft) => { 314 | draft.todos.push({ 315 | name: 'todo 4', 316 | }); 317 | }) 318 | ); 319 | [nextState, setState, controls] = result.current; 320 | 321 | act(() => controls.go(0)); 322 | [nextState, setState, controls] = result.current; 323 | 324 | expect(controls.position).toBe(0); 325 | expect(controls.patches.patches.length).toBe(3); 326 | expect(controls.canBack()).toBe(false); 327 | expect(controls.canForward()).toBe(true); 328 | 329 | act(() => controls.go(3)); 330 | [nextState, setState, controls] = result.current; 331 | 332 | expect(controls.position).toBe(3); 333 | expect(controls.patches.patches.length).toBe(3); 334 | expect(controls.canBack()).toBe(true); 335 | expect(controls.canForward()).toBe(false); 336 | 337 | act(() => controls.go(1)); 338 | [nextState, setState, controls] = result.current; 339 | 340 | expect(controls.position).toBe(1); 341 | expect(controls.patches.patches.length).toBe(3); 342 | expect(controls.canBack()).toBe(true); 343 | expect(controls.canForward()).toBe(true); 344 | 345 | expect(nextState).toEqual({ 346 | todos: [ 347 | { 348 | name: 'todo 1', 349 | }, 350 | { 351 | name: 'todo 2', 352 | }, 353 | ], 354 | }); 355 | 356 | act(() => 357 | setState((draft) => { 358 | draft.todos.push({ 359 | name: 'todo 5', 360 | }); 361 | }) 362 | ); 363 | [nextState, setState, controls] = result.current; 364 | 365 | act(() => controls.go(2)); 366 | [nextState, setState, controls] = result.current; 367 | 368 | expect(controls.position).toBe(2); 369 | expect(controls.patches.patches.length).toBe(2); 370 | expect(controls.canBack()).toBe(true); 371 | expect(controls.canForward()).toBe(false); 372 | 373 | expect(nextState).toEqual({ 374 | todos: [ 375 | { 376 | name: 'todo 1', 377 | }, 378 | { 379 | name: 'todo 2', 380 | }, 381 | { name: 'todo 5' }, 382 | ], 383 | }); 384 | 385 | const fnWarning = vi.spyOn(console, 'warn'); 386 | act(() => controls.go(3)); 387 | [nextState, setState, controls] = result.current; 388 | expect(fnWarning).toHaveBeenCalledWith(`Can't go forward to position 3`); 389 | expect(nextState).toEqual({ 390 | todos: [ 391 | { 392 | name: 'todo 1', 393 | }, 394 | { 395 | name: 'todo 2', 396 | }, 397 | { name: 'todo 5' }, 398 | ], 399 | }); 400 | 401 | act(() => controls.go(0)); 402 | [nextState, setState, controls] = result.current; 403 | 404 | act(() => controls.back()); 405 | [nextState, setState, controls] = result.current; 406 | 407 | expect(controls.position).toBe(0); 408 | expect(controls.patches.patches.length).toBe(2); 409 | expect(controls.canBack()).toBe(false); 410 | expect(controls.canForward()).toBe(true); 411 | 412 | expect(nextState).toEqual({ 413 | todos: [], 414 | }); 415 | 416 | expect(fnWarning).toHaveBeenCalledWith(`Can't go back to position -1`); 417 | 418 | //@ts-expect-error 419 | act(() => controls.archive()); 420 | [nextState, setState, controls] = result.current; 421 | expect(fnWarning).toHaveBeenCalledWith( 422 | `Auto archive is enabled, no need to archive manually` 423 | ); 424 | fnWarning.mockRestore(); 425 | }); 426 | 427 | it('[useTravel] with normal init state and disable autoArchive', () => { 428 | const { result } = renderHook(() => 429 | useTravel({ todos: [] } as { todos: { name: string }[] }, { 430 | autoArchive: false, 431 | }) 432 | ); 433 | let [nextState, setState, controls] = result.current; 434 | expect(nextState).toEqual({ todos: [] }); 435 | expect(controls.getHistory()).toEqual([{ todos: [] }]); 436 | 437 | act(() => 438 | setState((draft) => { 439 | draft.todos.push({ 440 | name: 'todo 1', 441 | }); 442 | draft.todos.push({ 443 | name: 'todo 2', 444 | }); 445 | }) 446 | ); 447 | [nextState, setState, controls] = result.current; 448 | expect(nextState).toEqual({ 449 | todos: [ 450 | { 451 | name: 'todo 1', 452 | }, 453 | { 454 | name: 'todo 2', 455 | }, 456 | ], 457 | }); 458 | expect(controls.patches.patches.length).toBe(1); 459 | expect(controls.position).toBe(1); 460 | expect(controls.getHistory()).toEqual([ 461 | { todos: [] }, 462 | { 463 | todos: [ 464 | { 465 | name: 'todo 1', 466 | }, 467 | { 468 | name: 'todo 2', 469 | }, 470 | ], 471 | }, 472 | ]); 473 | 474 | act(() => 475 | setState((draft) => { 476 | draft.todos.push({ 477 | name: 'todo 3', 478 | }); 479 | }) 480 | ); 481 | [nextState, setState, controls] = result.current; 482 | 483 | expect(controls.patches.patches.length).toBe(1); 484 | expect(controls.position).toBe(1); 485 | expect(controls.getHistory()).toEqual([ 486 | { todos: [] }, 487 | { 488 | todos: [ 489 | { 490 | name: 'todo 1', 491 | }, 492 | { 493 | name: 'todo 2', 494 | }, 495 | { 496 | name: 'todo 3', 497 | }, 498 | ], 499 | }, 500 | ]); 501 | 502 | act(() => controls.archive()); 503 | [nextState, setState, controls] = result.current; 504 | 505 | expect(controls.getHistory()).toEqual([ 506 | { todos: [] }, 507 | { 508 | todos: [ 509 | { 510 | name: 'todo 1', 511 | }, 512 | { 513 | name: 'todo 2', 514 | }, 515 | { 516 | name: 'todo 3', 517 | }, 518 | ], 519 | }, 520 | ]); 521 | 522 | act(() => controls.back()); 523 | [nextState, setState, controls] = result.current; 524 | 525 | expect(nextState).toEqual({ 526 | todos: [], 527 | }); 528 | 529 | act(() => controls.forward()); 530 | [nextState, setState, controls] = result.current; 531 | 532 | expect(nextState).toEqual({ 533 | todos: [ 534 | { 535 | name: 'todo 1', 536 | }, 537 | { 538 | name: 'todo 2', 539 | }, 540 | { 541 | name: 'todo 3', 542 | }, 543 | ], 544 | }); 545 | 546 | act(() => controls.go(0)); 547 | [nextState, setState, controls] = result.current; 548 | 549 | expect(nextState).toEqual({ 550 | todos: [], 551 | }); 552 | 553 | act(() => controls.go(1)); 554 | [nextState, setState, controls] = result.current; 555 | 556 | expect(nextState).toEqual({ 557 | todos: [ 558 | { 559 | name: 'todo 1', 560 | }, 561 | { 562 | name: 'todo 2', 563 | }, 564 | { 565 | name: 'todo 3', 566 | }, 567 | ], 568 | }); 569 | 570 | expect(controls.getHistory()).toEqual([ 571 | { 572 | todos: [], 573 | }, 574 | { 575 | todos: [ 576 | { 577 | name: 'todo 1', 578 | }, 579 | { 580 | name: 'todo 2', 581 | }, 582 | { 583 | name: 'todo 3', 584 | }, 585 | ], 586 | }, 587 | ]); 588 | expect(controls.position).toBe(1); 589 | 590 | act(() => 591 | setState((draft) => { 592 | draft.todos.push({ 593 | name: 'todo 4', 594 | }); 595 | }) 596 | ); 597 | [nextState, setState, controls] = result.current; 598 | expect(controls.position).toBe(2); 599 | expect(nextState).toEqual({ 600 | todos: [ 601 | { 602 | name: 'todo 1', 603 | }, 604 | { 605 | name: 'todo 2', 606 | }, 607 | { 608 | name: 'todo 3', 609 | }, 610 | { 611 | name: 'todo 4', 612 | }, 613 | ], 614 | }); 615 | 616 | // act(() => controls.archive()); 617 | // [nextState, setState, controls] = result.current; 618 | act(() => controls.back()); 619 | [nextState, setState, controls] = result.current; 620 | expect(controls.position).toBe(1); 621 | 622 | expect(nextState).toEqual({ 623 | todos: [ 624 | { 625 | name: 'todo 1', 626 | }, 627 | { 628 | name: 'todo 2', 629 | }, 630 | { 631 | name: 'todo 3', 632 | }, 633 | ], 634 | }); 635 | 636 | expect(controls.getHistory()).toEqual([ 637 | { 638 | todos: [], 639 | }, 640 | { 641 | todos: [ 642 | { 643 | name: 'todo 1', 644 | }, 645 | { 646 | name: 'todo 2', 647 | }, 648 | { 649 | name: 'todo 3', 650 | }, 651 | ], 652 | }, 653 | { 654 | todos: [ 655 | { 656 | name: 'todo 1', 657 | }, 658 | { 659 | name: 'todo 2', 660 | }, 661 | { 662 | name: 'todo 3', 663 | }, 664 | { 665 | name: 'todo 4', 666 | }, 667 | ], 668 | }, 669 | ]); 670 | 671 | act(() => controls.forward()); 672 | [nextState, setState, controls] = result.current; 673 | expect(nextState).toEqual({ 674 | todos: [ 675 | { 676 | name: 'todo 1', 677 | }, 678 | { 679 | name: 'todo 2', 680 | }, 681 | { 682 | name: 'todo 3', 683 | }, 684 | { 685 | name: 'todo 4', 686 | }, 687 | ], 688 | }); 689 | expect(controls.getHistory()).toEqual([ 690 | { 691 | todos: [], 692 | }, 693 | { 694 | todos: [ 695 | { 696 | name: 'todo 1', 697 | }, 698 | { 699 | name: 'todo 2', 700 | }, 701 | { 702 | name: 'todo 3', 703 | }, 704 | ], 705 | }, 706 | { 707 | todos: [ 708 | { 709 | name: 'todo 1', 710 | }, 711 | { 712 | name: 'todo 2', 713 | }, 714 | { 715 | name: 'todo 3', 716 | }, 717 | { 718 | name: 'todo 4', 719 | }, 720 | ], 721 | }, 722 | ]); 723 | 724 | act(() => controls.back()); 725 | [nextState, setState, controls] = result.current; 726 | 727 | expect(nextState).toEqual({ 728 | todos: [ 729 | { 730 | name: 'todo 1', 731 | }, 732 | { 733 | name: 'todo 2', 734 | }, 735 | { 736 | name: 'todo 3', 737 | }, 738 | ], 739 | }); 740 | 741 | expect(controls.getHistory()).toEqual([ 742 | { 743 | todos: [], 744 | }, 745 | { 746 | todos: [ 747 | { 748 | name: 'todo 1', 749 | }, 750 | { 751 | name: 'todo 2', 752 | }, 753 | { 754 | name: 'todo 3', 755 | }, 756 | ], 757 | }, 758 | { 759 | todos: [ 760 | { 761 | name: 'todo 1', 762 | }, 763 | { 764 | name: 'todo 2', 765 | }, 766 | { 767 | name: 'todo 3', 768 | }, 769 | { 770 | name: 'todo 4', 771 | }, 772 | ], 773 | }, 774 | ]); 775 | 776 | act(() => 777 | setState((draft) => { 778 | draft.todos.push({ 779 | name: 'todo 5', 780 | }); 781 | }) 782 | ); 783 | [nextState, setState, controls] = result.current; 784 | 785 | expect(nextState).toEqual({ 786 | todos: [ 787 | { 788 | name: 'todo 1', 789 | }, 790 | { 791 | name: 'todo 2', 792 | }, 793 | { 794 | name: 'todo 3', 795 | }, 796 | { 797 | name: 'todo 5', 798 | }, 799 | ], 800 | }); 801 | 802 | expect(controls.getHistory()).toEqual([ 803 | { 804 | todos: [], 805 | }, 806 | { 807 | todos: [ 808 | { 809 | name: 'todo 1', 810 | }, 811 | { 812 | name: 'todo 2', 813 | }, 814 | { 815 | name: 'todo 3', 816 | }, 817 | ], 818 | }, 819 | { 820 | todos: [ 821 | { 822 | name: 'todo 1', 823 | }, 824 | { 825 | name: 'todo 2', 826 | }, 827 | { 828 | name: 'todo 3', 829 | }, 830 | { 831 | name: 'todo 5', 832 | }, 833 | ], 834 | }, 835 | ]); 836 | 837 | act(() => controls.go(1)); 838 | [nextState, setState, controls] = result.current; 839 | 840 | expect(nextState).toEqual({ 841 | todos: [ 842 | { 843 | name: 'todo 1', 844 | }, 845 | { 846 | name: 'todo 2', 847 | }, 848 | { 849 | name: 'todo 3', 850 | }, 851 | ], 852 | }); 853 | expect(controls.getHistory()).toEqual([ 854 | { 855 | todos: [], 856 | }, 857 | { 858 | todos: [ 859 | { 860 | name: 'todo 1', 861 | }, 862 | { 863 | name: 'todo 2', 864 | }, 865 | { 866 | name: 'todo 3', 867 | }, 868 | ], 869 | }, 870 | { 871 | todos: [ 872 | { 873 | name: 'todo 1', 874 | }, 875 | { 876 | name: 'todo 2', 877 | }, 878 | { 879 | name: 'todo 3', 880 | }, 881 | { 882 | name: 'todo 5', 883 | }, 884 | ], 885 | }, 886 | ]); 887 | 888 | act(() => controls.reset()); 889 | [nextState, setState, controls] = result.current; 890 | expect(nextState).toEqual({ todos: [] }); 891 | expect(controls.getHistory()).toEqual([{ todos: [] }]); 892 | }); 893 | 894 | it('[useTravel] maxHistory', () => { 895 | const { result } = renderHook(() => 896 | useTravel(0, { 897 | maxHistory: 3, 898 | }) 899 | ); 900 | let [nextState, setState, controls] = result.current; 901 | expect(nextState).toEqual(0); 902 | expect(controls.position).toEqual(0); 903 | expect(controls.getHistory()).toEqual([0]); 904 | 905 | act(() => setState(() => 1)); 906 | [nextState, setState, controls] = result.current; 907 | expect(nextState).toEqual(1); 908 | expect(controls.position).toEqual(1); 909 | expect(controls.getHistory()).toEqual([0, 1]); 910 | 911 | act(() => setState(2)); 912 | [nextState, setState, controls] = result.current; 913 | expect(nextState).toEqual(2); 914 | expect(controls.position).toEqual(2); 915 | expect(controls.getHistory()).toEqual([0, 1, 2]); 916 | 917 | act(() => setState(3)); 918 | [nextState, setState, controls] = result.current; 919 | expect(nextState).toEqual(3); 920 | expect(controls.position).toEqual(3); 921 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); 922 | 923 | act(() => setState(4)); 924 | [nextState, setState, controls] = result.current; 925 | expect(nextState).toEqual(4); 926 | expect(controls.position).toEqual(3); 927 | expect(controls.getHistory()).toEqual([1, 2, 3, 4]); 928 | 929 | act(() => setState(5)); 930 | [nextState, setState, controls] = result.current; 931 | expect(nextState).toEqual(5); 932 | expect(controls.position).toEqual(3); 933 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 934 | expect(controls.canBack()).toBe(true); 935 | expect(controls.canForward()).toBe(false); 936 | 937 | act(() => controls.back()); 938 | [nextState, setState, controls] = result.current; 939 | expect(nextState).toEqual(4); 940 | expect(controls.position).toEqual(2); 941 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 942 | expect(controls.canBack()).toBe(true); 943 | expect(controls.canForward()).toBe(true); 944 | 945 | act(() => controls.back()); 946 | [nextState, setState, controls] = result.current; 947 | expect(nextState).toEqual(3); 948 | expect(controls.position).toEqual(1); 949 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 950 | expect(controls.canBack()).toBe(true); 951 | expect(controls.canForward()).toBe(true); 952 | 953 | act(() => controls.back()); 954 | [nextState, setState, controls] = result.current; 955 | expect(nextState).toEqual(2); 956 | expect(controls.position).toEqual(0); 957 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 958 | expect(controls.canBack()).toBe(false); 959 | expect(controls.canForward()).toBe(true); 960 | 961 | act(() => controls.back()); 962 | [nextState, setState, controls] = result.current; 963 | expect(nextState).toEqual(2); 964 | expect(controls.position).toEqual(0); 965 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 966 | expect(controls.canBack()).toBe(false); 967 | expect(controls.canForward()).toBe(true); 968 | 969 | act(() => controls.forward()); 970 | [nextState, setState, controls] = result.current; 971 | expect(nextState).toEqual(3); 972 | expect(controls.position).toEqual(1); 973 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 974 | expect(controls.canBack()).toBe(true); 975 | expect(controls.canForward()).toBe(true); 976 | 977 | act(() => controls.forward()); 978 | [nextState, setState, controls] = result.current; 979 | expect(nextState).toEqual(4); 980 | expect(controls.position).toEqual(2); 981 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 982 | expect(controls.canBack()).toBe(true); 983 | expect(controls.canForward()).toBe(true); 984 | 985 | act(() => controls.forward()); 986 | [nextState, setState, controls] = result.current; 987 | expect(nextState).toEqual(5); 988 | expect(controls.position).toEqual(3); 989 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 990 | expect(controls.canBack()).toBe(true); 991 | expect(controls.canForward()).toBe(false); 992 | 993 | act(() => controls.forward()); 994 | [nextState, setState, controls] = result.current; 995 | expect(nextState).toEqual(5); 996 | expect(controls.position).toEqual(3); 997 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 998 | expect(controls.canBack()).toBe(true); 999 | expect(controls.canForward()).toBe(false); 1000 | }); 1001 | 1002 | it('[useTravel] maxHistory with autoArchive: false', () => { 1003 | let { result } = renderHook(() => 1004 | useTravel(0, { 1005 | maxHistory: 3, 1006 | autoArchive: false, 1007 | }) 1008 | ); 1009 | let [nextState, setState, controls] = result.current; 1010 | expect(nextState).toEqual(0); 1011 | expect(controls.position).toEqual(0); 1012 | expect(controls.getHistory()).toEqual([0]); 1013 | 1014 | act(() => setState(() => 1)); 1015 | [nextState, setState, controls] = result.current; 1016 | expect(nextState).toEqual(1); 1017 | expect(controls.position).toEqual(1); 1018 | expect(controls.getHistory()).toEqual([0, 1]); 1019 | 1020 | act(() => controls.archive()); 1021 | [nextState, setState, controls] = result.current; 1022 | expect(nextState).toEqual(1); 1023 | expect(controls.position).toEqual(1); 1024 | expect(controls.getHistory()).toEqual([0, 1]); 1025 | 1026 | act(() => setState(2)); 1027 | [nextState, setState, controls] = result.current; 1028 | expect(nextState).toEqual(2); 1029 | expect(controls.position).toEqual(2); 1030 | expect(controls.getHistory()).toEqual([0, 1, 2]); 1031 | 1032 | act(() => controls.archive()); 1033 | [nextState, setState, controls] = result.current; 1034 | expect(nextState).toEqual(2); 1035 | expect(controls.position).toEqual(2); 1036 | expect(controls.getHistory()).toEqual([0, 1, 2]); 1037 | 1038 | act(() => setState(3)); 1039 | [nextState, setState, controls] = result.current; 1040 | expect(nextState).toEqual(3); 1041 | expect(controls.position).toEqual(3); 1042 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); 1043 | 1044 | act(() => controls.archive()); 1045 | [nextState, setState, controls] = result.current; 1046 | expect(nextState).toEqual(3); 1047 | expect(controls.position).toEqual(3); 1048 | expect(controls.getHistory()).toEqual([0, 1, 2, 3]); 1049 | 1050 | act(() => setState(4)); 1051 | [nextState, setState, controls] = result.current; 1052 | expect(nextState).toEqual(4); 1053 | expect(controls.position).toEqual(3); 1054 | expect(controls.getHistory()).toEqual([1, 2, 3, 4]); 1055 | 1056 | act(() => controls.archive()); 1057 | [nextState, setState, controls] = result.current; 1058 | expect(nextState).toEqual(4); 1059 | expect(controls.position).toEqual(3); 1060 | expect(controls.getHistory()).toEqual([1, 2, 3, 4]); 1061 | 1062 | act(() => setState(5)); 1063 | [nextState, setState, controls] = result.current; 1064 | expect(nextState).toEqual(5); 1065 | expect(controls.position).toEqual(3); 1066 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 1067 | expect(controls.canBack()).toBe(true); 1068 | expect(controls.canForward()).toBe(false); 1069 | 1070 | act(() => controls.archive()); 1071 | [nextState, setState, controls] = result.current; 1072 | expect(nextState).toEqual(5); 1073 | expect(controls.position).toEqual(3); 1074 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 1075 | expect(controls.canBack()).toBe(true); 1076 | expect(controls.canForward()).toBe(false); 1077 | 1078 | act(() => controls.archive()); 1079 | [nextState, setState, controls] = result.current; 1080 | expect(nextState).toEqual(5); 1081 | expect(controls.position).toEqual(3); 1082 | expect(controls.getHistory()).toEqual([2, 3, 4, 5]); 1083 | expect(controls.canBack()).toBe(true); 1084 | expect(controls.canForward()).toBe(false); 1085 | 1086 | act(() => setState(6)); 1087 | [nextState, setState, controls] = result.current; 1088 | expect(nextState).toEqual(6); 1089 | expect(controls.position).toEqual(3); 1090 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1091 | expect(controls.canBack()).toBe(true); 1092 | expect(controls.canForward()).toBe(false); 1093 | 1094 | act(() => controls.archive()); 1095 | [nextState, setState, controls] = result.current; 1096 | expect(nextState).toEqual(6); 1097 | expect(controls.position).toEqual(3); 1098 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1099 | expect(controls.canBack()).toBe(true); 1100 | expect(controls.canForward()).toBe(false); 1101 | 1102 | act(() => controls.back()); 1103 | [nextState, setState, controls] = result.current; 1104 | expect(nextState).toEqual(5); 1105 | expect(controls.position).toEqual(2); 1106 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1107 | expect(controls.canBack()).toBe(true); 1108 | expect(controls.canForward()).toBe(true); 1109 | // return; 1110 | 1111 | act(() => controls.back()); 1112 | [nextState, setState, controls] = result.current; 1113 | expect(nextState).toEqual(4); 1114 | expect(controls.position).toEqual(1); 1115 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1116 | expect(controls.canBack()).toBe(true); 1117 | expect(controls.canForward()).toBe(true); 1118 | 1119 | act(() => controls.back()); 1120 | [nextState, setState, controls] = result.current; 1121 | expect(nextState).toEqual(3); 1122 | expect(controls.position).toEqual(0); 1123 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1124 | expect(controls.canBack()).toBe(false); 1125 | expect(controls.canForward()).toBe(true); 1126 | 1127 | act(() => controls.back()); 1128 | [nextState, setState, controls] = result.current; 1129 | expect(nextState).toEqual(3); 1130 | expect(controls.position).toEqual(0); 1131 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1132 | expect(controls.canBack()).toBe(false); 1133 | expect(controls.canForward()).toBe(true); 1134 | 1135 | act(() => controls.forward()); 1136 | [nextState, setState, controls] = result.current; 1137 | expect(nextState).toEqual(4); 1138 | expect(controls.position).toEqual(1); 1139 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1140 | expect(controls.canBack()).toBe(true); 1141 | expect(controls.canForward()).toBe(true); 1142 | 1143 | act(() => controls.forward()); 1144 | [nextState, setState, controls] = result.current; 1145 | expect(nextState).toEqual(5); 1146 | expect(controls.position).toEqual(2); 1147 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1148 | expect(controls.canBack()).toBe(true); 1149 | expect(controls.canForward()).toBe(true); 1150 | 1151 | act(() => controls.forward()); 1152 | [nextState, setState, controls] = result.current; 1153 | expect(nextState).toEqual(6); 1154 | expect(controls.position).toEqual(3); 1155 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1156 | expect(controls.canBack()).toBe(true); 1157 | expect(controls.canForward()).toBe(false); 1158 | 1159 | act(() => controls.forward()); 1160 | [nextState, setState, controls] = result.current; 1161 | expect(nextState).toEqual(6); 1162 | expect(controls.position).toEqual(3); 1163 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1164 | expect(controls.canBack()).toBe(true); 1165 | expect(controls.canForward()).toBe(false); 1166 | 1167 | act(() => controls.back()); 1168 | [nextState, setState, controls] = result.current; 1169 | expect(nextState).toEqual(5); 1170 | expect(controls.position).toEqual(2); 1171 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1172 | expect(controls.canBack()).toBe(true); 1173 | expect(controls.canForward()).toBe(true); 1174 | 1175 | expect(controls.patches).toMatchInlineSnapshot(` 1176 | { 1177 | "inversePatches": [ 1178 | [ 1179 | { 1180 | "op": "replace", 1181 | "path": [], 1182 | "value": 3, 1183 | }, 1184 | ], 1185 | [ 1186 | { 1187 | "op": "replace", 1188 | "path": [], 1189 | "value": 4, 1190 | }, 1191 | ], 1192 | [ 1193 | { 1194 | "op": "replace", 1195 | "path": [], 1196 | "value": 5, 1197 | }, 1198 | ], 1199 | ], 1200 | "patches": [ 1201 | [ 1202 | { 1203 | "op": "replace", 1204 | "path": [], 1205 | "value": 4, 1206 | }, 1207 | ], 1208 | [ 1209 | { 1210 | "op": "replace", 1211 | "path": [], 1212 | "value": 5, 1213 | }, 1214 | ], 1215 | [ 1216 | { 1217 | "op": "replace", 1218 | "path": [], 1219 | "value": 6, 1220 | }, 1221 | ], 1222 | ], 1223 | } 1224 | `); 1225 | 1226 | result = renderHook(() => 1227 | useTravel(nextState, { 1228 | maxHistory: 3, 1229 | autoArchive: false, 1230 | initialPatches: controls.patches, 1231 | initialPosition: controls.position, 1232 | }) 1233 | ).result; 1234 | [nextState, setState, controls] = result.current; 1235 | 1236 | act(() => controls.back()); 1237 | [nextState, setState, controls] = result.current; 1238 | 1239 | expect(nextState).toEqual(4); 1240 | expect(controls.position).toEqual(1); 1241 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1242 | expect(controls.canBack()).toBe(true); 1243 | expect(controls.canForward()).toBe(true); 1244 | 1245 | act(() => controls.reset()); 1246 | [nextState, setState, controls] = result.current; 1247 | 1248 | expect(nextState).toEqual(5); 1249 | expect(controls.position).toEqual(2); 1250 | expect(controls.getHistory()).toEqual([3, 4, 5, 6]); 1251 | expect(controls.canBack()).toBe(true); 1252 | expect(controls.canForward()).toBe(true); 1253 | expect(controls.patches).toMatchInlineSnapshot(` 1254 | { 1255 | "inversePatches": [ 1256 | [ 1257 | { 1258 | "op": "replace", 1259 | "path": [], 1260 | "value": 3, 1261 | }, 1262 | ], 1263 | [ 1264 | { 1265 | "op": "replace", 1266 | "path": [], 1267 | "value": 4, 1268 | }, 1269 | ], 1270 | [ 1271 | { 1272 | "op": "replace", 1273 | "path": [], 1274 | "value": 5, 1275 | }, 1276 | ], 1277 | ], 1278 | "patches": [ 1279 | [ 1280 | { 1281 | "op": "replace", 1282 | "path": [], 1283 | "value": 4, 1284 | }, 1285 | ], 1286 | [ 1287 | { 1288 | "op": "replace", 1289 | "path": [], 1290 | "value": 5, 1291 | }, 1292 | ], 1293 | [ 1294 | { 1295 | "op": "replace", 1296 | "path": [], 1297 | "value": 6, 1298 | }, 1299 | ], 1300 | ], 1301 | } 1302 | `); 1303 | 1304 | act(() => setState(() => 7)); 1305 | [nextState, setState, controls] = result.current; 1306 | 1307 | expect(nextState).toEqual(7); 1308 | expect(controls.position).toEqual(3); 1309 | expect(controls.getHistory()).toEqual([3, 4, 5, 7]); 1310 | expect(controls.canBack()).toBe(true); 1311 | expect(controls.canForward()).toBe(false); 1312 | expect(controls.patches).toMatchInlineSnapshot(` 1313 | { 1314 | "inversePatches": [ 1315 | [ 1316 | { 1317 | "op": "replace", 1318 | "path": [], 1319 | "value": 3, 1320 | }, 1321 | ], 1322 | [ 1323 | { 1324 | "op": "replace", 1325 | "path": [], 1326 | "value": 4, 1327 | }, 1328 | ], 1329 | [ 1330 | { 1331 | "op": "replace", 1332 | "path": [], 1333 | "value": 5, 1334 | }, 1335 | ], 1336 | ], 1337 | "patches": [ 1338 | [ 1339 | { 1340 | "op": "replace", 1341 | "path": [], 1342 | "value": 4, 1343 | }, 1344 | ], 1345 | [ 1346 | { 1347 | "op": "replace", 1348 | "path": [], 1349 | "value": 5, 1350 | }, 1351 | ], 1352 | [ 1353 | { 1354 | "op": "replace", 1355 | "path": [], 1356 | "value": 7, 1357 | }, 1358 | ], 1359 | ], 1360 | } 1361 | `); 1362 | 1363 | act(() => setState(() => 8)); 1364 | [nextState, setState, controls] = result.current; 1365 | 1366 | expect(nextState).toEqual(8); 1367 | expect(controls.position).toEqual(3); 1368 | expect(controls.getHistory()).toEqual([3, 4, 5, 8]); 1369 | expect(controls.canBack()).toBe(true); 1370 | expect(controls.canForward()).toBe(false); 1371 | expect(controls.patches).toMatchInlineSnapshot(` 1372 | { 1373 | "inversePatches": [ 1374 | [ 1375 | { 1376 | "op": "replace", 1377 | "path": [], 1378 | "value": 3, 1379 | }, 1380 | ], 1381 | [ 1382 | { 1383 | "op": "replace", 1384 | "path": [], 1385 | "value": 4, 1386 | }, 1387 | ], 1388 | [ 1389 | { 1390 | "op": "replace", 1391 | "path": [], 1392 | "value": 7, 1393 | }, 1394 | { 1395 | "op": "replace", 1396 | "path": [], 1397 | "value": 5, 1398 | }, 1399 | ], 1400 | ], 1401 | "patches": [ 1402 | [ 1403 | { 1404 | "op": "replace", 1405 | "path": [], 1406 | "value": 4, 1407 | }, 1408 | ], 1409 | [ 1410 | { 1411 | "op": "replace", 1412 | "path": [], 1413 | "value": 5, 1414 | }, 1415 | ], 1416 | [ 1417 | { 1418 | "op": "replace", 1419 | "path": [], 1420 | "value": 7, 1421 | }, 1422 | { 1423 | "op": "replace", 1424 | "path": [], 1425 | "value": 8, 1426 | }, 1427 | ], 1428 | ], 1429 | } 1430 | `); 1431 | 1432 | act(() => controls.archive()); 1433 | [nextState, setState, controls] = result.current; 1434 | 1435 | expect(nextState).toEqual(8); 1436 | expect(controls.position).toEqual(3); 1437 | expect(controls.getHistory()).toEqual([3, 4, 5, 8]); 1438 | expect(controls.canBack()).toBe(true); 1439 | expect(controls.canForward()).toBe(false); 1440 | expect(controls.patches).toMatchInlineSnapshot(` 1441 | { 1442 | "inversePatches": [ 1443 | [ 1444 | { 1445 | "op": "replace", 1446 | "path": [], 1447 | "value": 3, 1448 | }, 1449 | ], 1450 | [ 1451 | { 1452 | "op": "replace", 1453 | "path": [], 1454 | "value": 4, 1455 | }, 1456 | ], 1457 | [ 1458 | { 1459 | "op": "replace", 1460 | "path": [], 1461 | "value": 5, 1462 | }, 1463 | ], 1464 | ], 1465 | "patches": [ 1466 | [ 1467 | { 1468 | "op": "replace", 1469 | "path": [], 1470 | "value": 4, 1471 | }, 1472 | ], 1473 | [ 1474 | { 1475 | "op": "replace", 1476 | "path": [], 1477 | "value": 5, 1478 | }, 1479 | ], 1480 | [ 1481 | { 1482 | "op": "replace", 1483 | "path": [], 1484 | "value": 8, 1485 | }, 1486 | ], 1487 | ], 1488 | } 1489 | `); 1490 | }); 1491 | 1492 | it('[useTravel] - back() with autoArchive: false', () => { 1493 | let { result } = renderHook(() => 1494 | useTravel(0, { 1495 | maxHistory: 3, 1496 | autoArchive: false, 1497 | }) 1498 | ); 1499 | let [nextState, setState, controls] = result.current; 1500 | expect(nextState).toEqual(0); 1501 | expect(controls.position).toEqual(0); 1502 | expect(controls.getHistory()).toEqual([0]); 1503 | 1504 | act(() => setState(() => 1)); 1505 | [nextState, setState, controls] = result.current; 1506 | expect(nextState).toEqual(1); 1507 | expect(controls.position).toEqual(1); 1508 | expect(controls.getHistory()).toEqual([0, 1]); 1509 | 1510 | act(() => setState(() => 2)); 1511 | [nextState, setState, controls] = result.current; 1512 | expect(nextState).toEqual(2); 1513 | expect(controls.position).toEqual(1); 1514 | expect(controls.getHistory()).toEqual([0, 2]); 1515 | expect(controls.canBack()).toBe(true); 1516 | expect(controls.canForward()).toBe(false); 1517 | expect(controls.canArchive()).toBe(true); 1518 | 1519 | act(() => controls.back()); 1520 | [nextState, setState, controls] = result.current; 1521 | expect(nextState).toEqual(0); 1522 | expect(controls.position).toEqual(0); 1523 | expect(controls.getHistory()).toEqual([0, 2]); 1524 | expect(controls.canBack()).toBe(false); 1525 | expect(controls.canForward()).toBe(true); 1526 | expect(controls.canArchive()).toBe(false); 1527 | }); 1528 | 1529 | it('[useTravel] - back() with autoArchive: false', () => { 1530 | let { result } = renderHook(() => 1531 | useTravel(0, { 1532 | maxHistory: 3, 1533 | autoArchive: false, 1534 | }) 1535 | ); 1536 | let [nextState, setState, controls] = result.current; 1537 | expect(nextState).toEqual(0); 1538 | expect(controls.position).toEqual(0); 1539 | expect(controls.getHistory()).toEqual([0]); 1540 | 1541 | act(() => setState(() => 1)); 1542 | [nextState, setState, controls] = result.current; 1543 | expect(nextState).toEqual(1); 1544 | expect(controls.position).toEqual(1); 1545 | expect(controls.getHistory()).toEqual([0, 1]); 1546 | 1547 | act(() => setState(() => 2)); 1548 | [nextState, setState, controls] = result.current; 1549 | expect(nextState).toEqual(2); 1550 | expect(controls.position).toEqual(1); 1551 | expect(controls.getHistory()).toEqual([0, 2]); 1552 | expect(controls.canBack()).toBe(true); 1553 | expect(controls.canForward()).toBe(false); 1554 | expect(controls.canArchive()).toBe(true); 1555 | 1556 | act(() => controls.archive()); 1557 | [nextState, setState, controls] = result.current; 1558 | expect(nextState).toEqual(2); 1559 | expect(controls.position).toEqual(1); 1560 | expect(controls.getHistory()).toEqual([0, 2]); 1561 | expect(controls.canBack()).toBe(true); 1562 | expect(controls.canForward()).toBe(false); 1563 | expect(controls.canArchive()).toBe(false); 1564 | }); 1565 | 1566 | it('[useTravel] basic test with autoArchive: false', () => { 1567 | const { result } = renderHook(() => 1568 | useTravel(0, { 1569 | maxHistory: 10, 1570 | initialPatches: { 1571 | patches: [], 1572 | inversePatches: [], 1573 | }, 1574 | autoArchive: false, 1575 | }) 1576 | ); 1577 | 1578 | let [state, setState, controls] = result.current; 1579 | 1580 | // initial state 1581 | expect(state).toBe(0); 1582 | expect(controls.position).toBe(0); 1583 | expect(controls.getHistory()).toEqual([0]); 1584 | expect(controls.canBack()).toBe(false); 1585 | expect(controls.canForward()).toBe(false); 1586 | expect(controls.canArchive()).toBe(false); 1587 | 1588 | // simulate the first click of Increment button 1589 | act(() => { 1590 | setState(state + 1); // 0 + 1 = 1 1591 | controls.archive(); 1592 | }); 1593 | [state, setState, controls] = result.current; 1594 | 1595 | expect(state).toBe(1); 1596 | expect(controls.position).toBe(1); 1597 | expect(controls.getHistory()).toEqual([0, 1]); 1598 | expect(controls.canBack()).toBe(true); 1599 | expect(controls.canForward()).toBe(false); 1600 | expect(controls.canArchive()).toBe(false); 1601 | 1602 | // simulate the second click of Increment button 1603 | act(() => { 1604 | setState(state + 1); // 1 + 1 = 2 1605 | controls.archive(); 1606 | }); 1607 | [state, setState, controls] = result.current; 1608 | 1609 | expect(state).toBe(2); 1610 | expect(controls.position).toBe(2); 1611 | expect(controls.getHistory()).toEqual([0, 1, 2]); 1612 | expect(controls.canBack()).toBe(true); 1613 | expect(controls.canForward()).toBe(false); 1614 | 1615 | // simulate the click of Decrement button 1616 | act(() => { 1617 | setState(state - 1); // 2 - 1 = 1 1618 | controls.archive(); 1619 | }); 1620 | [state, setState, controls] = result.current; 1621 | 1622 | expect(state).toBe(1); 1623 | expect(controls.position).toBe(3); 1624 | expect(controls.getHistory()).toEqual([0, 1, 2, 1]); 1625 | expect(controls.canBack()).toBe(true); 1626 | expect(controls.canForward()).toBe(false); 1627 | 1628 | // test the back function 1629 | act(() => controls.back()); 1630 | [state, setState, controls] = result.current; 1631 | 1632 | expect(state).toBe(2); 1633 | expect(controls.position).toBe(2); 1634 | expect(controls.getHistory()).toEqual([0, 1, 2, 1]); 1635 | 1636 | // test the again back function 1637 | act(() => controls.back()); 1638 | [state, setState, controls] = result.current; 1639 | 1640 | expect(state).toBe(1); 1641 | expect(controls.position).toBe(1); 1642 | expect(controls.getHistory()).toEqual([0, 1, 2, 1]); 1643 | 1644 | // test the forward function 1645 | act(() => controls.forward()); 1646 | [state, setState, controls] = result.current; 1647 | 1648 | expect(state).toBe(2); 1649 | expect(controls.position).toBe(2); 1650 | expect(controls.getHistory()).toEqual([0, 1, 2, 1]); 1651 | 1652 | // test the modify state from the middle position 1653 | act(() => { 1654 | setState(state + 5); // 2 + 5 = 7 1655 | controls.archive(); 1656 | }); 1657 | [state, setState, controls] = result.current; 1658 | 1659 | // here may expose bug: the history after the modify state from the middle position 1660 | expect(state).toBe(7); 1661 | expect(controls.position).toBe(3); 1662 | // the history should be truncated and add new state 1663 | expect(controls.getHistory()).toEqual([0, 1, 2, 7]); 1664 | expect(controls.canBack()).toBe(true); 1665 | expect(controls.canForward()).toBe(false); 1666 | 1667 | // test the state after multiple continuous operations 1668 | act(() => { 1669 | setState(state * 2); // 7 * 2 = 14 1670 | controls.archive(); 1671 | }); 1672 | [state, setState, controls] = result.current; 1673 | 1674 | expect(state).toBe(14); 1675 | expect(controls.position).toBe(4); 1676 | expect(controls.getHistory()).toEqual([0, 1, 2, 7, 14]); 1677 | 1678 | // test the correctness of patches 1679 | expect(controls.patches.patches.length).toBe(4); 1680 | expect(controls.patches.inversePatches.length).toBe(4); 1681 | 1682 | // test the back to the initial position 1683 | act(() => controls.go(0)); 1684 | [state, setState, controls] = result.current; 1685 | 1686 | expect(state).toBe(0); 1687 | expect(controls.position).toBe(0); 1688 | 1689 | // test the forward to the various states from the initial position 1690 | act(() => controls.go(4)); 1691 | [state, setState, controls] = result.current; 1692 | 1693 | expect(state).toBe(14); 1694 | expect(controls.position).toBe(4); 1695 | 1696 | // test the case without calling archive 1697 | act(() => { 1698 | setState(state + 100); // 14 + 100 = 114 1699 | // this time without calling controls.archive() 1700 | }); 1701 | [state, setState, controls] = result.current; 1702 | 1703 | expect(state).toBe(114); 1704 | expect(controls.position).toBe(5); 1705 | expect(controls.canArchive()).toBe(true); // should be able to archive 1706 | expect(controls.getHistory()).toEqual([0, 1, 2, 7, 14, 114]); // the history should show the temporary state 1707 | 1708 | // test the navigation with temporary state 1709 | act(() => controls.back()); 1710 | [state, setState, controls] = result.current; 1711 | 1712 | expect(state).toBe(14); 1713 | expect(controls.position).toBe(4); 1714 | expect(controls.canArchive()).toBe(false); // the temporary state should be automatically archived 1715 | 1716 | // test the reset function 1717 | act(() => controls.reset()); 1718 | [state, setState, controls] = result.current; 1719 | 1720 | expect(state).toBe(0); 1721 | expect(controls.position).toBe(0); 1722 | expect(controls.getHistory()).toEqual([0]); 1723 | expect(controls.patches.patches).toEqual([]); 1724 | expect(controls.patches.inversePatches).toEqual([]); 1725 | }); 1726 | 1727 | it('[useTravel] basic test with autoArchive: false and temporary state', () => { 1728 | const { result } = renderHook(() => 1729 | useTravel(0, { 1730 | maxHistory: 10, 1731 | initialPatches: { 1732 | patches: [], 1733 | inversePatches: [], 1734 | }, 1735 | autoArchive: false, 1736 | }) 1737 | ); 1738 | 1739 | let [state, setState, controls] = result.current; 1740 | 1741 | // initial state 1742 | expect(state).toBe(0); 1743 | expect(controls.position).toBe(0); 1744 | expect(controls.getHistory()).toEqual([0]); 1745 | expect(controls.canBack()).toBe(false); 1746 | expect(controls.canForward()).toBe(false); 1747 | expect(controls.canArchive()).toBe(false); 1748 | 1749 | // simulate the first click of Increment button 1750 | act(() => { 1751 | setState(state + 1); 1752 | }); 1753 | [state, setState, controls] = result.current; 1754 | 1755 | expect(state).toBe(1); 1756 | expect(controls.position).toBe(1); 1757 | expect(controls.getHistory()).toEqual([0, 1]); 1758 | expect(controls.canBack()).toBe(true); 1759 | expect(controls.canForward()).toBe(false); 1760 | expect(controls.canArchive()).toBe(true); 1761 | 1762 | act(() => { 1763 | setState(state + 1); 1764 | }); 1765 | [state, setState, controls] = result.current; 1766 | 1767 | expect(state).toBe(2); 1768 | expect(controls.position).toBe(1); 1769 | expect(controls.getHistory()).toEqual([0, 2]); 1770 | expect(controls.canBack()).toBe(true); 1771 | expect(controls.canForward()).toBe(false); 1772 | expect(controls.canArchive()).toBe(true); 1773 | 1774 | act(() => { 1775 | controls.archive(); 1776 | }); 1777 | [state, setState, controls] = result.current; 1778 | 1779 | expect(state).toBe(2); 1780 | expect(controls.position).toBe(1); 1781 | expect(controls.getHistory()).toEqual([0, 2]); 1782 | expect(controls.canBack()).toBe(true); 1783 | expect(controls.canForward()).toBe(false); 1784 | expect(controls.canArchive()).toBe(false); 1785 | }); 1786 | 1787 | it('[useTravel] basic test with autoArchive: false and object state', () => { 1788 | const { result } = renderHook(() => 1789 | useTravel( 1790 | { count: 0 }, 1791 | { 1792 | maxHistory: 10, 1793 | initialPatches: { 1794 | patches: [], 1795 | inversePatches: [], 1796 | }, 1797 | autoArchive: false, 1798 | } 1799 | ) 1800 | ); 1801 | 1802 | let [state, setState, controls] = result.current; 1803 | 1804 | // initial state 1805 | expect(state).toEqual({ count: 0 }); 1806 | expect(controls.position).toBe(0); 1807 | expect(controls.getHistory()).toEqual([{ count: 0 }]); 1808 | expect(controls.canBack()).toBe(false); 1809 | expect(controls.canForward()).toBe(false); 1810 | expect(controls.canArchive()).toBe(false); 1811 | 1812 | // simulate the first click of Increment button 1813 | act(() => { 1814 | setState({ count: state.count + 1 }); 1815 | }); 1816 | [state, setState, controls] = result.current; 1817 | 1818 | expect(state).toEqual({ count: 1 }); 1819 | expect(controls.position).toBe(1); 1820 | expect(controls.getHistory()).toEqual([{ count: 0 }, { count: 1 }]); 1821 | expect(controls.canBack()).toBe(true); 1822 | expect(controls.canForward()).toBe(false); 1823 | expect(controls.canArchive()).toBe(true); 1824 | 1825 | act(() => { 1826 | setState({ count: state.count + 1 }); 1827 | }); 1828 | [state, setState, controls] = result.current; 1829 | 1830 | expect(state).toEqual({ count: 2 }); 1831 | expect(controls.position).toBe(1); 1832 | expect(controls.getHistory()).toEqual([{ count: 0 }, { count: 2 }]); 1833 | expect(controls.canBack()).toBe(true); 1834 | expect(controls.canForward()).toBe(false); 1835 | expect(controls.canArchive()).toBe(true); 1836 | 1837 | act(() => { 1838 | controls.archive(); 1839 | }); 1840 | [state, setState, controls] = result.current; 1841 | 1842 | expect(state).toEqual({ count: 2 }); 1843 | expect(controls.position).toBe(1); 1844 | expect(controls.getHistory()).toEqual([{ count: 0 }, { count: 2 }]); 1845 | expect(controls.canBack()).toBe(true); 1846 | expect(controls.canForward()).toBe(false); 1847 | expect(controls.canArchive()).toBe(false); 1848 | }); 1849 | }); 1850 | --------------------------------------------------------------------------------