├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── coverage.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples └── web │ ├── .eslintrc.json │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── pages │ ├── chained.tsx │ ├── diff.tsx │ ├── index.tsx │ ├── input.tsx │ ├── nested.tsx │ ├── options.tsx │ ├── persist.tsx │ ├── reactive.tsx │ ├── separate.tsx │ ├── setstate.tsx │ ├── slices.tsx │ ├── subscribeWithSelector.tsx │ ├── throttle.tsx │ └── wrapped.tsx │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── size └── package.json ├── src ├── index.ts ├── temporal.ts └── types.ts ├── tests ├── __mocks__ │ └── zustand │ │ └── index.ts ├── __tests__ │ ├── createVanillaTemporal.test.ts │ ├── options.test.ts │ ├── react.test.tsx │ └── zundo.test.ts ├── package.json ├── tsconfig.json ├── vitest.config.ts └── vitest.setup.ts ├── tsconfig.json ├── tsup.config.ts └── zundo-mascot.png /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "files": [ 4 | "*.ts", 5 | "*.mjs", 6 | "*.js" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [charkour] 2 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [18.x, 23.x] 13 | zustand: [ 14 | 4.2.0, # Oldest zustand TS supported 15 | 4.0.0, # Oldest zustand JS supported 16 | 4, # Latest zustand v5 supported 17 | latest # Latest zustand supported 18 | ] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - uses: pnpm/action-setup@v4 29 | name: Install pnpm 30 | id: pnpm-install 31 | with: 32 | version: 8 33 | run_install: false 34 | 35 | - name: Install Dependencies 36 | run: pnpm install 37 | 38 | - name: Install Zustand ${{ matrix.zustand }} 39 | run: pnpm add zustand@${{ matrix.zustand }} -w 40 | 41 | - name: Patch tsup config 42 | if: ${{ matrix.zustand == '4.0.0' }} 43 | # Patch the tsup config to use `dts: false` for Zustand 4.0.0 44 | run: | 45 | sed -i~ 's/dts: true/dts: false/' tsup.config.ts 46 | 47 | - name: Build 48 | run: pnpm run build 49 | 50 | - name: Test 51 | run: pnpm run test:ci 52 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: 'Test' 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | permissions: 10 | # Required to checkout the code 11 | contents: read 12 | # Required to put a comment into the pull-request 13 | pull-requests: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: pnpm/action-setup@v4 18 | with: 19 | version: 8 20 | run_install: false 21 | - name: Install Node 22 | uses: actions/setup-node@v4 23 | - name: Install Deps 24 | run: pnpm install 25 | - name: Test 26 | run: pnpm test 27 | - name: Report Coverage 28 | # Set if: always() to also generate the report if tests are failing 29 | # Only works if you set `reportOnFailure: true` in your vite config as specified above 30 | if: always() 31 | uses: davelosert/vitest-coverage-report-action@v2 32 | with: 33 | working-directory: ./tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | dist 4 | coverage 5 | .DS_Store 6 | .next/ -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "trailingComma": "all", 7 | "quoteProps": "as-needed", 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "partialize", 4 | "tsup", 5 | "zundo", 6 | "zustand" 7 | ], 8 | "typescript.tsdk": "node_modules/typescript/lib" 9 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Please write out the purpose of the PR and steps to test the PR. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Charles Kornoelje 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍜 Zundo 2 | 3 | enable time-travel in your apps. undo/redo middleware for [zustand](https://github.com/pmndrs/zustand). built with zustand. <700 B 4 | 5 | ![gif displaying undo feature](https://github.com/charkour/zundo/raw/v0.2.0/zundo.gif) 6 | 7 | [![Build Size](https://img.shields.io/bundlephobia/minzip/zundo?label=bundle%20size&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/result?p=zundo) 8 | [![Version](https://img.shields.io/npm/v/zundo?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/zundo) 9 | [![Downloads](https://img.shields.io/npm/dt/zundo?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/zundo) 10 | 11 | Try a live [demo](https://codesandbox.io/s/currying-flower-2dom9?file=/src/App.tsx) 12 | 13 | ## Install 14 | 15 | ```sh 16 | npm i zustand zundo 17 | ``` 18 | 19 | > zustand v4.2.0+ or v5 is required for TS usage. v4.0.0 or higher is required for JS usage. 20 | > Node 16 or higher is required. 21 | 22 | ## Background 23 | 24 | - Solves the issue of managing state in complex user applications 25 | - "It Just Works" mentality 26 | - Small and fast 27 | - Provides simple middleware to add undo/redo capabilities 28 | - Leverages zustand for state management 29 | - Works with multiple stores in the same app 30 | - Has an unopinionated and extensible API 31 | 32 |
33 | Bear wearing a button up shirt textured with blue recycle symbols eating a bowl of noodles with chopsticks. 34 |
35 | 36 | ## First create a vanilla store with `temporal` middleware 37 | 38 | This returns the familiar store accessible by a hook! But now your store also tracks past states. 39 | 40 | ```tsx 41 | import { create } from 'zustand'; 42 | import { temporal } from 'zundo'; 43 | 44 | // Define the type of your store state (typescript) 45 | interface StoreState { 46 | bears: number; 47 | increasePopulation: () => void; 48 | removeAllBears: () => void; 49 | } 50 | 51 | // Use `temporal` middleware to create a store with undo/redo capabilities 52 | const useStoreWithUndo = create()( 53 | temporal((set) => ({ 54 | bears: 0, 55 | increasePopulation: () => set((state) => ({ bears: state.bears + 1 })), 56 | removeAllBears: () => set({ bears: 0 }), 57 | })), 58 | ); 59 | ``` 60 | 61 | ## Then access `temporal` functions and properties of your store 62 | 63 | Your zustand store will now have an attached `temporal` object that provides access to useful time-travel utilities, including `undo`, `redo`, and `clear`! 64 | 65 | ```tsx 66 | const App = () => { 67 | const { bears, increasePopulation, removeAllBears } = useStoreWithUndo(); 68 | // See API section for temporal.getState() for all functions and 69 | // properties provided by `temporal`, but note that properties, such as `pastStates` and `futureStates`, are not reactive when accessed directly from the store. 70 | const { undo, redo, clear } = useStoreWithUndo.temporal.getState(); 71 | 72 | return ( 73 | <> 74 | bears: {bears} 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | }; 83 | ``` 84 | 85 | ## For reactive changes to member properties of the `temporal` object, optionally convert to a React store hook 86 | 87 | In React, to subscribe components or custom hooks to member properties of the `temporal` object (like the array of `pastStates` or `currentStates`), you can create a `useTemporalStore` hook. 88 | 89 | ```tsx 90 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 91 | import type { TemporalState } from 'zundo'; 92 | 93 | function useTemporalStore(): TemporalState; 94 | function useTemporalStore(selector: (state: TemporalState) => T): T; 95 | function useTemporalStore( 96 | selector: (state: TemporalState) => T, 97 | equality: (a: T, b: T) => boolean, 98 | ): T; 99 | function useTemporalStore( 100 | selector?: (state: TemporalState) => T, 101 | equality?: (a: T, b: T) => boolean, 102 | ) { 103 | return useStoreWithEqualityFn(useStoreWithUndo.temporal, selector!, equality); 104 | } 105 | 106 | const App = () => { 107 | const { bears, increasePopulation, removeAllBears } = useStoreWithUndo(); 108 | // changes to pastStates and futureStates will now trigger a reactive component rerender 109 | const { undo, redo, clear, pastStates, futureStates } = useTemporalStore( 110 | (state) => state, 111 | ); 112 | 113 | return ( 114 | <> 115 |

bears: {bears}

116 |

pastStates: {JSON.stringify(pastStates)}

117 |

futureStates: {JSON.stringify(futureStates)}

118 | 119 | 120 | 121 | 122 | 123 | 124 | ); 125 | }; 126 | ``` 127 | 128 | ## API 129 | 130 | ### The Middleware 131 | 132 | `(config: StateCreator, options?: ZundoOptions) => StateCreator` 133 | 134 | `zundo` has one export: `temporal`. It is used as middleware for `create` from zustand. The `config` parameter is your store created by zustand. The second `options` param is optional and has the following API. 135 | 136 | ### Bear's eye view 137 | 138 | ```tsx 139 | export interface ZundoOptions { 140 | partialize?: (state: TState) => PartialTState; 141 | limit?: number; 142 | equality?: (pastState: PartialTState, currentState: PartialTState) => boolean; 143 | diff?: ( 144 | pastState: Partial, 145 | currentState: Partial, 146 | ) => Partial | null; 147 | onSave?: (pastState: TState, currentState: TState) => void; 148 | handleSet?: ( 149 | handleSet: StoreApi['setState'], 150 | ) => StoreApi['setState']; 151 | pastStates?: Partial[]; 152 | futureStates?: Partial[]; 153 | wrapTemporal?: ( 154 | storeInitializer: StateCreator< 155 | _TemporalState, 156 | [StoreMutatorIdentifier, unknown][], 157 | [] 158 | >, 159 | ) => StateCreator< 160 | _TemporalState, 161 | [StoreMutatorIdentifier, unknown][], 162 | [StoreMutatorIdentifier, unknown][] 163 | >; 164 | } 165 | ``` 166 | 167 | ### Exclude fields from being tracked in history 168 | 169 | `partialize?: (state: TState) => PartialTState` 170 | 171 | Use the `partialize` option to omit or include specific fields. Pass a callback that returns the desired fields. This can also be used to exclude fields. By default, the entire state object is tracked. 172 | 173 | ```tsx 174 | // Only field1 and field2 will be tracked 175 | const useStoreWithUndoA = create()( 176 | temporal( 177 | (set) => ({ 178 | // your store fields 179 | }), 180 | { 181 | partialize: (state) => { 182 | const { field1, field2, ...rest } = state; 183 | return { field1, field2 }; 184 | }, 185 | }, 186 | ), 187 | ); 188 | 189 | // Everything besides field1 and field2 will be tracked 190 | const useStoreWithUndoB = create()( 191 | temporal( 192 | (set) => ({ 193 | // your store fields 194 | }), 195 | { 196 | partialize: (state) => { 197 | const { field1, field2, ...rest } = state; 198 | return rest; 199 | }, 200 | }, 201 | ), 202 | ); 203 | ``` 204 | 205 | #### `useTemporalStore` with `partialize` 206 | 207 | If converting temporal store to a React Store Hook with typescript, be sure to define the type of your partialized state 208 | 209 | ```tsx 210 | interface StoreState { 211 | bears: number; 212 | untrackedStateField: number; 213 | } 214 | 215 | type PartializedStoreState = Pick; 216 | 217 | const useStoreWithUndo = create()( 218 | temporal( 219 | (set) => ({ 220 | bears: 0, 221 | untrackedStateField: 0, 222 | }), 223 | { 224 | partialize: (state) => { 225 | const { bears } = state; 226 | return { bears }; 227 | }, 228 | }, 229 | ), 230 | ); 231 | 232 | const useTemporalStore = ( 233 | // Use partalized StoreState type as the generic here 234 | selector: (state: TemporalState) => T, 235 | ) => useStore(useStoreWithUndo.temporal, selector); 236 | ``` 237 | 238 | ### Limit number of historical states stored 239 | 240 | `limit?: number` 241 | 242 | For performance reasons, you may want to limit the number of previous and future states stored in history. Setting `limit` will limit the number of previous and future states stored in the `temporal` store. When the limit is reached, the oldest state is dropped. By default, no limit is set. 243 | 244 | ```tsx 245 | const useStoreWithUndo = create()( 246 | temporal( 247 | (set) => ({ 248 | // your store fields 249 | }), 250 | { limit: 100 }, 251 | ), 252 | ); 253 | ``` 254 | 255 | ### Prevent unchanged states from getting stored in history 256 | 257 | `equality?: (pastState: PartialTState, currentState: PartialTState) => boolean` 258 | 259 | By default, a state snapshot is stored in `temporal` history when _any_ `zustand` state setter is called—even if no value in your `zustand` store has changed. 260 | 261 | If all of your `zustand` state setters modify state in a way that you want tracked in history, this default is sufficient. 262 | 263 | However, for more precise control over when a state snapshot is stored in `zundo` history, you can provide an `equality` function. 264 | 265 | You can write your own equality function or use something like [`fast-equals`](https://github.com/planttheidea/fast-equals), [`fast-deep-equal`](https://github.com/epoberezkin/fast-deep-equal), [`zustand/shallow`](https://github.com/pmndrs/zustand/blob/main/src/shallow.ts), [`lodash.isequal`](https://www.npmjs.com/package/lodash.isequal), or [`underscore.isEqual`](https://github.com/jashkenas/underscore/blob/master/modules/isEqual.js). 266 | 267 | #### Example with deep equality 268 | 269 | ```tsx 270 | import isDeepEqual from 'fast-deep-equal'; 271 | 272 | // Use a deep equality function to only store history when currentState has changed 273 | const useStoreWithUndo = create()( 274 | temporal( 275 | (set) => ({ 276 | // your store fields 277 | }), 278 | // a state snapshot will only be stored in history when currentState is not deep-equal to pastState 279 | // Note: this can also be more concisely written as {equality: isDeepEqual} 280 | { 281 | equality: (pastState, currentState) => 282 | isDeepEqual(pastState, currentState), 283 | }, 284 | ), 285 | ); 286 | ``` 287 | 288 | #### Example with shallow equality 289 | 290 | If your state or specific application does not require deep equality (for example, if you're only using non-nested primitives), you may for performance reasons choose to use a shallow equality fn that does not do deep comparison. 291 | 292 | ```tsx 293 | import shallow from 'zustand/shallow'; 294 | 295 | const useStoreWithUndo = create()( 296 | temporal( 297 | (set) => ({ 298 | // your store fields 299 | }), 300 | // a state snapshot will only be stored in history when currentState is not deep-equal to pastState 301 | // Note: this can also be more concisely written as {equality: shallow} 302 | { 303 | equality: (pastState, currentState) => shallow(pastState, currentState), 304 | }, 305 | ), 306 | ); 307 | ``` 308 | 309 | #### Example with custom equality 310 | 311 | You can also just as easily use custom equality functions for your specific application 312 | 313 | ```tsx 314 | const useStoreWithUndo = create()( 315 | temporal( 316 | (set) => ({ 317 | // your store fields 318 | }), 319 | { 320 | // Only track history when field1 AND field2 diverge from their pastState 321 | // Why would you do this? I don't know! But you can do it! 322 | equality: (pastState, currentState) => 323 | pastState.field1 !== currentState.field1 && 324 | pastState.field2 !== currentState.field2, 325 | }, 326 | ), 327 | ); 328 | ``` 329 | 330 | ### Store state delta rather than full object 331 | 332 | `diff?: (pastState: Partial, currentState: Partial) => Partial | null` 333 | 334 | For performance reasons, you may want to store the state delta rather than the complete (potentially partialized) state object. This can be done by passing a `diff` function. The `diff` function should return an object that represents the difference between the past and current state. By default, the full state object is stored. 335 | 336 | If `diff` returns `null`, the state change will not be tracked. This is helpful for a conditionally storing past states or if you have a `doNothing` action that does not change the state. 337 | 338 | You can write your own or use something like [`microdiff`](https://github.com/AsyncBanana/microdiff), [`just-diff`](https://github.com/angus-c/just/tree/master/packages/collection-diff), or [`deep-object-diff`](https://github.com/mattphillips/deep-object-diff). 339 | 340 | ```tsx 341 | const useStoreWithUndo = create()( 342 | temporal( 343 | (set) => ({ 344 | // your store fields 345 | }), 346 | { 347 | diff: (pastState, currentState) => { 348 | const myDiff = diff(currentState, pastState); 349 | const newStateFromDiff = myDiff.reduce( 350 | (acc, difference) => { 351 | type Key = keyof typeof currentState; 352 | if (difference.type === 'CHANGE') { 353 | const pathAsString = difference.path.join('.') as Key; 354 | acc[pathAsString] = difference.value; 355 | } 356 | return acc; 357 | }, 358 | {} as Partial, 359 | ); 360 | return isEmpty(newStateFromDiff) ? null : newStateFromDiff; 361 | }, 362 | }, 363 | ), 364 | ); 365 | ``` 366 | 367 | ### Callback when temporal store is updated 368 | 369 | `onSave?: (pastState: TState, currentState: TState) => void` 370 | 371 | Sometimes, you may need to call a function when the temporal store is updated. This can be configured using `onSave` in the options, or by programmatically setting the callback if you need lexical context (see the `TemporalState` API below for more information). 372 | 373 | ```tsx 374 | import { shallow } from 'zustand/shallow'; 375 | 376 | const useStoreWithUndo = create()( 377 | temporal( 378 | (set) => ({ 379 | // your store fields 380 | }), 381 | { onSave: (state) => console.log('saved', state) }, 382 | ), 383 | ); 384 | ``` 385 | 386 | ### Cool-off period 387 | 388 | ```typescript 389 | handleSet?: (handleSet: StoreApi['setState']) => ( 390 | pastState: Parameters['setState']>[0], 391 | // `replace` will likely be deprecated and removed in the future 392 | replace: Parameters['setState']>[1], 393 | currentState: PartialTState, 394 | deltaState?: Partial | null, 395 | ) => void 396 | ``` 397 | 398 | Sometimes multiple state changes might happen in a short amount of time and you only want to store one change in history. To do so, we can utilize the `handleSet` callback to set a timeout to prevent new changes from being stored in history. This can be used with something like [`throttle-debounce`](https://github.com/niksy/throttle-debounce), [`just-throttle`](https://github.com/angus-c/just/tree/master/packages/function-throttle), [`just-debounce-it`](https://github.com/angus-c/just/tree/master/packages/function-debounce), [`lodash.throttle`](https://www.npmjs.com/package/lodash.throttle), or [`lodash.debounce`](https://www.npmjs.com/package/lodash.debounce). This a way to provide middleware to the temporal store's setter function. 399 | 400 | ```tsx 401 | const useStoreWithUndo = create()( 402 | temporal( 403 | (set) => ({ 404 | // your store fields 405 | }), 406 | { 407 | handleSet: (handleSet) => 408 | throttle((state) => { 409 | console.info('handleSet called'); 410 | handleSet(state); 411 | }, 1000), 412 | }, 413 | ), 414 | ); 415 | ``` 416 | 417 | ### Initialize temporal store with past and future states 418 | 419 | `pastStates?: Partial[]` 420 | 421 | `futureStates?: Partial[]` 422 | 423 | You can initialize the temporal store with past and future states. This is useful when you want to load a previous state from a database or initialize the store with a default state. By default, the temporal store is initialized with an empty array of past and future states. 424 | 425 | > Note: The `pastStates` and `futureStates` do not respect the limit set in the options. If you want to limit the number of past and future states, you must do so manually prior to initializing the store. 426 | 427 | ```tsx 428 | const useStoreWithUndo = create()( 429 | temporal( 430 | (set) => ({ 431 | // your store fields 432 | }), 433 | { 434 | pastStates: [{ field1: 'value1' }, { field1: 'value2' }], 435 | futureStates: [{ field1: 'value3' }, { field1: 'value4' }], 436 | }, 437 | ), 438 | ); 439 | ``` 440 | 441 | ### Wrap temporal store 442 | 443 | `wrapTemporal?: (storeInitializer: StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], []>) => StateCreator<_TemporalState, [StoreMutatorIdentifier, unknown][], [StoreMutatorIdentifier, unknown][]>` 444 | 445 | You can wrap the temporal store with your own middleware. This is useful if you want to add additional functionality to the temporal store. For example, you can add `persist` middleware to the temporal store to persist the past and future states to local storage. 446 | 447 | For a full list of middleware, see [zustand middleware](https://www.npmjs.com/package/lodash.debounce) and [third-party zustand libraries](https://github.com/pmndrs/zustand#third-party-libraries). 448 | 449 | > Note: The `temporal` middleware can be added to the `temporal` store. This way, you could track the history of the history. 🤯 450 | 451 | ```tsx 452 | import { persist } from 'zustand/middleware'; 453 | 454 | const useStoreWithUndo = create()( 455 | persist( // <-- persist 456 | temporal( 457 | (set) => ({ 458 | // your store fields 459 | }), 460 | { 461 | wrapTemporal: (storeInitializer) => { 462 | persist(storeInitializer, { // <-- persist 463 | name: 'temporal-persist' 464 | }); 465 | }, 466 | } 467 | ) 468 | ) 469 | ); 470 | ``` 471 | 472 | > In the example above, note that we use `persist` twice. The outer persist is persisting your user facing store, and the inner persist, as part of the temporal options, will persist the temporal store that's created by the middleware. Simply put: there are two zustand stores, so you must persist both. 473 | 474 | 475 | ### `useStore.temporal` 476 | 477 | When using zustand with the `temporal` middleware, a `temporal` object is attached to your vanilla or React-based store. `temporal` is a vanilla zustand store: see [StoreApi from](https://github.com/pmndrs/zustand/blob/f0ff30f7c431f6bf25b3cb439d065a7e61355df4/src/vanilla.ts#L8) zustand for more details. 478 | 479 | Use `temporal.getState()` to access to temporal store! 480 | 481 | > While `setState`, `subscribe`, and `destroy` exist on `temporal`, you should not need to use them. 482 | 483 | ### `useStore.temporal.getState()` 484 | 485 | `temporal.getState()` returns the `TemporalState` which contains `undo`, `redo`, and other helpful functions and fields. 486 | 487 | ```tsx 488 | interface TemporalState { 489 | pastStates: TState[]; 490 | futureStates: TState[]; 491 | 492 | undo: (steps?: number) => void; 493 | redo: (steps?: number) => void; 494 | clear: () => void; 495 | 496 | isTracking: boolean; 497 | pause: () => void; 498 | resume: () => void; 499 | 500 | setOnSave: (onSave: onSave) => void; 501 | } 502 | ``` 503 | 504 | #### **Going back in time** 505 | 506 | `pastStates: TState[]` 507 | 508 | `pastStates` is an array of previous states. The most recent previous state is at the end of the array. This is the state that will be applied when `undo` is called. 509 | 510 | #### **Forward to the future** 511 | 512 | `futureStates: TState[]` 513 | 514 | `futureStates` is an array of future states. States are added when `undo` is called. The most recent future state is at the end of the array. This is the state that will be applied when `redo` is called. The future states are the "past past states." 515 | 516 | #### **Back it up** 517 | 518 | `undo: (steps?: number) => void` 519 | 520 | `undo`: call function to apply previous state (if there are previous states). Optionally pass a number of steps to undo to go back multiple state at once. 521 | 522 | #### **Take it back now y'all** 523 | 524 | `redo: (steps?: number) => void` 525 | 526 | `redo`: call function to apply future state (if there are future states). Future states are "previous previous states." Optionally pass a number of steps to redo go forward multiple states at once. 527 | 528 | #### **Remove all knowledge of time** 529 | 530 | `clear: () => void` 531 | 532 | `clear`: call function to remove all stored states from your undo store. Sets `pastStates` and `futureStates` to arrays with length of 0. _Warning:_ clearing cannot be undone. 533 | 534 | **Dispatching a new state will clear all of the future states.** 535 | 536 | #### **Stop and start history** 537 | 538 | `isTracking: boolean` 539 | 540 | `isTracking`: a stateful flag in the `temporal` store that indicates whether the `temporal` store is tracking state changes or not. Possible values are `true` or `false`. To programmatically pause and resume tracking, use `pause()` and `resume()` explained below. 541 | 542 | #### **Pause tracking of history** 543 | 544 | `pause: () => void` 545 | 546 | `pause`: call function to pause tracking state changes. This will prevent new states from being stored in history within the temporal store. Sets `isTracking` to `false`. 547 | 548 | #### **Resume tracking of history** 549 | 550 | `resume: () => void` 551 | 552 | `resume`: call function to resume tracking state changes. This will allow new states to be stored in history within the temporal store. Sets `isTracking` to `true`. 553 | 554 | #### **Programmatically add middleware to the setter** 555 | 556 | `setOnSave: (onSave: (pastState: State, currentState: State) => void) => void` 557 | 558 | `setOnSave`: call function to set a callback that will be called when the temporal store is updated. This can be used to call the temporal store setter using values from the lexical context. This is useful when needing to throttle or debounce updates to the temporal store. 559 | 560 | ## Community 561 | 562 | `zundo` is used by several projects and teams including [Alibaba](https://github.com/alibaba/x-render), [Dify.ai](https://github.com/langgenius/dify), [Stability AI](https://github.com/Stability-AI/StableStudio), [Yext](https://github.com/yext/studio), [KaotoIO](https://github.com/KaotoIO/kaoto-ui), and [NutSH.ai](https://github.com/SysCV/nutsh). 563 | 564 | If this library is useful to you, please consider [sponsoring](https://github.com/sponsors/charkour) the project. Thank you! 565 | 566 | PRs are welcome! [pnpm](https://pnpm.io/) is used as a package manager. Run `pnpm install` to install local dependencies. Thank you for contributing! 567 | 568 | ## Examples 569 | 570 | - [Basic](https://codesandbox.io/s/currying-flower-2dom9?file=/src/App.tsx) 571 | - [with lodash.debounce](https://codesandbox.io/s/zundo-handleset-debounce-nq7ml7?file=/src/App.tsx) 572 | - [with just-debounce-it](https://codesandbox.io/p/sandbox/zundo-forked-9yp7df) 573 | - [SubscribeWithSelector](https://codesandbox.io/s/zundo-with-subscribe-with-selector-forked-mug69t) 574 | - [canUndo, canRedo, undoDepth, redoDepth](https://codesandbox.io/s/zundo-canundo-and-undodepth-l6jclx?file=/src/App.tsx:572-731) 575 | - [with deep equal](https://codesandbox.io/p/sandbox/zundo-deep-equal-qg69lj) 576 | - [with input](https://stackblitz.com/edit/vitejs-vite-jqngm9?file=src%2FApp.tsx) 577 | - [with slices pattern](https://codesandbox.io/p/sandbox/pttx6c) 578 | 579 | ## Migrate from v1 to v2 580 | 581 |
582 | Click to expand 583 | 584 | ## v2.0.0 - Smaller and more flexible 585 | 586 | v2.0.0 is a complete rewrite of zundo. It is smaller and more flexible. It also has a smaller bundle size and allows you to opt into specific performance trade-offs. The API has changed slightly. See the [API](#api) section for more details. Below is a summary of the changes as well as steps to migrate from v1 to v2. 587 | 588 | ### Breaking Changes 589 | 590 | #### Middleware Option Changes 591 | 592 | - `include` and `exclude` options are now handled by the `partialize` option. 593 | - `allowUnchanged` option is now handled by the `equality` option. By default, all state changes are tracked. In v1, we bundled `lodash.isequal` to handle equality checks. In v2, you are able to use any function. 594 | - `historyDepthLimit` option has been renamed to `limit`. 595 | - `coolOffDurationMs` option is now handled by the `handleSet` option by wrapping the setter function with a throttle or debounce function. 596 | 597 | #### Import changes 598 | 599 | - The middleware is called `temporal` rather than `undoMiddleware`. 600 | 601 | ### New Features 602 | 603 | #### New Options 604 | 605 | - `partialize` option to omit or include specific fields. By default, the entire state object is tracked. 606 | - `limit` option to limit the number of previous and future states stored in history. 607 | - `equality` option to use a custom equality function to determine when a state change should be tracked. By default, all state changes are tracked. 608 | - `diff` option to store state delta rather than full object. 609 | - `onSave` option to call a function when the temporal store is updated. 610 | - `handleSet` option to throttle or debounce state changes. 611 | - `pastStates` and `futureStates` options to initialize the temporal store with past and future states. 612 | - `wrapTemporal` option to wrap the temporal store with middleware. The `temporal` store is a vanilla zustand store. 613 | 614 | #### New `temporal.getState()` API 615 | 616 | - `undo`, `redo`, and `clear` functions are now always defined. They can no longer be `undefined`. 617 | - `undo()` and `redo()` functions now accept an optional `steps` parameter to go back or forward multiple states at once. 618 | - `isTracking` flag, and `pause`, and `resume` functions are now available on the temporal store. 619 | - `setOnSave` function is now available on the temporal store to change the `onSave` behavior after the store has been created. 620 | 621 | ### Migration Steps 622 | 623 | 1. Update zustand to v4.3.0 or higher 624 | 2. Update zundo to v2.0.0 or higher 625 | 3. Update your store to use the new API 626 | 4. Update imports 627 | 628 | ```diff 629 | - import { undoMiddleware } from 'zundo'; 630 | + import { temporal } from 'zundo'; 631 | ``` 632 | 633 | - If you're using `include` or `exclude`, use the new `partialize` option 634 | 635 | ```tsx 636 | // v1.6.0 637 | // Only field1 and field2 will be tracked 638 | const useStoreA = create()( 639 | undoMiddleware( 640 | set => ({ ... }), 641 | { include: ['field1', 'field2'] } 642 | ) 643 | ); 644 | 645 | // Everything besides field1 and field2 will be tracked 646 | const useStoreB = create()( 647 | undoMiddleware( 648 | set => ({ ... }), 649 | { exclude: ['field1', 'field2'] } 650 | ) 651 | ); 652 | 653 | // v2.0.0 654 | // Only field1 and field2 will be tracked 655 | const useStoreA = create()( 656 | temporal( 657 | (set) => ({ 658 | // your store fields 659 | }), 660 | { 661 | partialize: (state) => { 662 | const { field1, field2, ...rest } = state; 663 | return { field1, field2 }; 664 | }, 665 | }, 666 | ), 667 | ); 668 | 669 | // Everything besides field1 and field2 will be tracked 670 | const useStoreB = create()( 671 | temporal( 672 | (set) => ({ 673 | // your store fields 674 | }), 675 | { 676 | partialize: (state) => { 677 | const { field1, field2, ...rest } = state; 678 | return rest; 679 | }, 680 | }, 681 | ), 682 | ); 683 | ``` 684 | 685 | - If you're using `allowUnchanged`, use the new `equality` option 686 | 687 | ```tsx 688 | // v1.6.0 689 | // Use an existing `allowUnchanged` option 690 | const useStore = create()( 691 | undoMiddleware( 692 | set => ({ ... }), 693 | { allowUnchanged: true } 694 | ) 695 | ); 696 | 697 | // v2.0.0 698 | // Use an existing equality function 699 | import { shallow } from 'zustand/shallow'; // or use `lodash.isequal` or any other equality function 700 | 701 | // Use an existing equality function 702 | const useStoreA = create()( 703 | temporal( 704 | (set) => ({ 705 | // your store fields 706 | }), 707 | { equality: shallow }, 708 | ), 709 | ); 710 | ``` 711 | 712 | - If you're using `historyDepthLimit`, use the new `limit` option 713 | 714 | ```tsx 715 | // v1.6.0 716 | // Use an existing `historyDepthLimit` option 717 | const useStore = create()( 718 | undoMiddleware( 719 | set => ({ ... }), 720 | { historyDepthLimit: 100 } 721 | ) 722 | ); 723 | 724 | // v2.0.0 725 | // Use `limit` option 726 | const useStore = create()( 727 | temporal( 728 | (set) => ({ 729 | // your store fields 730 | }), 731 | { limit: 100 }, 732 | ), 733 | ); 734 | ``` 735 | 736 | - If you're using `coolOffDurationMs`, use the new `handleSet` option 737 | 738 | ```tsx 739 | // v1.6.0 740 | // Use an existing `coolOffDurationMs` option 741 | const useStore = create()( 742 | undoMiddleware( 743 | set => ({ ... }), 744 | { coolOfDurationMs: 1000 } 745 | ) 746 | ); 747 | 748 | // v2.0.0 749 | // Use `handleSet` option 750 | const withTemporal = temporal( 751 | (set) => ({ 752 | // your store fields 753 | }), 754 | { 755 | handleSet: (handleSet) => 756 | throttle((state) => { 757 | console.info('handleSet called'); 758 | handleSet(state); 759 | }, 1000), 760 | }, 761 | ); 762 | ``` 763 | 764 |
765 | 766 | ## Road Map 767 | 768 | - [ ] create nicer API, or a helper hook in react land (useTemporal). or vanilla version of the it 769 | - [ ] support history branches rather than clearing the future states 770 | - [ ] track state for multiple stores at once 771 | 772 | ## Author 773 | 774 | Charles Kornoelje ([@\_charkour](https://twitter.com/_charkour)) 775 | 776 | ## Versioning 777 | 778 | View the [releases](https://github.com/charkour/zundo/releases) for the change log. This project follows semantic versioning. 779 | 780 | ## Illustration Credits 781 | 782 | Ivo Ilić ([@theivoson](https://twitter.com/theivoson)) 783 | -------------------------------------------------------------------------------- /examples/web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": false 3 | } 4 | -------------------------------------------------------------------------------- /examples/web/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, run the development server: 4 | 5 | ```bash 6 | yarn dev 7 | ``` 8 | 9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 10 | 11 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 12 | 13 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.js`. 14 | 15 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 16 | 17 | ## Learn More 18 | 19 | To learn more about Next.js, take a look at the following resources: 20 | 21 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 22 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 23 | 24 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 25 | 26 | ## Deploy on Vercel 27 | 28 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. 29 | 30 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 31 | -------------------------------------------------------------------------------- /examples/web/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/web/next.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | /** 3 | * @type {import('next').NextConfig} 4 | */ 5 | const nextConfig = { 6 | reactStrictMode: true, 7 | }; 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /examples/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint", 9 | "start": "next start" 10 | }, 11 | "dependencies": { 12 | "just-throttle": "4.2.0", 13 | "lodash.merge": "4.6.2", 14 | "lodash.throttle": "4.1.1", 15 | "microdiff": "1.5.0", 16 | "next": "15.1.5", 17 | "react": "19.0.0", 18 | "react-dom": "19.0.0", 19 | "zundo": "workspace:*", 20 | "zustand": "5.0.3" 21 | }, 22 | "devDependencies": { 23 | "@types/lodash.merge": "4.6.9", 24 | "@types/lodash.throttle": "4.1.9", 25 | "@types/node": "22.10.7", 26 | "@types/react": "19.0.7", 27 | "eslint": "9.18.0", 28 | "typescript": "5.7.3" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/web/pages/chained.tsx: -------------------------------------------------------------------------------- 1 | import { temporal } from 'zundo'; 2 | import { create } from 'zustand'; 3 | import { devtools, persist } from 'zustand/middleware'; 4 | import { immer } from 'zustand/middleware/immer'; 5 | 6 | interface MyState { 7 | count: number; 8 | increment: () => void; 9 | decrement: () => void; 10 | } 11 | 12 | // const withZundo = zundo( 13 | // (set) => ({ 14 | // count: 0, 15 | // increment: () => set((state) => ({ count: state.count + 1 })), 16 | // decrement: () => set((state) => ({ count: state.count - 1 })), 17 | // }), 18 | // { 19 | // handleSet: (handleSet) => 20 | // throttle((state) => { 21 | // console.error('handleSet called'); 22 | // handleSet(state); 23 | // }, 1000), 24 | // }, 25 | // ); 26 | 27 | // TODO: doing this one by one does not work with the types. 28 | // const withPersist = persist(withZundo); 29 | 30 | // const originalStore = createVanilla(withZundo); 31 | 32 | // const useStore = create(originalStore); 33 | 34 | const useChainedStore = create()( 35 | devtools( 36 | temporal( 37 | immer( 38 | persist( 39 | (set) => ({ 40 | count: 0, 41 | increment: () => set((state) => ({ count: state.count + 1 })), 42 | decrement: () => set((state) => ({ count: state.count - 1 })), 43 | }), 44 | { 45 | name: 'test', 46 | }, 47 | ), 48 | ), 49 | ), 50 | ), 51 | ); 52 | 53 | export default function Web() { 54 | const { count, increment, decrement } = useChainedStore(); 55 | const { undo, futureStates, pastStates } = 56 | useChainedStore.temporal.getState(); 57 | 58 | return ( 59 |
60 |

Web

61 |
62 | 63 | 64 | {count} 65 |
66 |

Future States

67 |
{JSON.stringify(futureStates)}
68 |

Previous States

69 |
{JSON.stringify(pastStates)}
70 | 71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /examples/web/pages/diff.tsx: -------------------------------------------------------------------------------- 1 | import { temporal, type TemporalState } from 'zundo'; 2 | import { createStore } from 'zustand'; 3 | import { shallow } from 'zustand/shallow'; 4 | import diff from 'microdiff'; 5 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 6 | 7 | interface MyState { 8 | count: number; 9 | increment: () => void; 10 | decrement: () => void; 11 | count2: number; 12 | increment2: () => void; 13 | decrement2: () => void; 14 | doNothing: () => void; 15 | } 16 | 17 | const withZundo = temporal( 18 | (set) => ({ 19 | count: 0, 20 | increment: () => set((state) => ({ count: state.count + 1 })), 21 | decrement: () => set((state) => ({ count: state.count - 1 })), 22 | count2: 0, 23 | increment2: () => set((state) => ({ count2: state.count2 + 1 })), 24 | decrement2: () => set((state) => ({ count2: state.count2 - 1 })), 25 | doNothing: () => set((state) => state), 26 | }), 27 | { 28 | diff: (pastState, currentState) => { 29 | const myDiff = diff(currentState, pastState); 30 | const newStateFromDiff = myDiff.reduce( 31 | (acc, difference) => { 32 | type State = typeof currentState; 33 | type Key = keyof State; 34 | if (difference.type === 'CHANGE') { 35 | // 'count' | 'count2' | 'increment' | 'decrement' | 'increment2' | 'decrement2' | 'doNothing' 36 | const pathAsString = difference.path.join('.') as Key; 37 | // number | () => void | undefined 38 | const value = difference.value; 39 | acc[pathAsString] = value; 40 | } 41 | return acc; 42 | }, 43 | {} as Partial, 44 | ); 45 | return isEmpty(newStateFromDiff) ? null : newStateFromDiff; 46 | }, 47 | }, 48 | ); 49 | 50 | const originalStore = createStore(withZundo); 51 | 52 | const useBaseStore = ( 53 | selector: (state: MyState) => T, 54 | equality?: (a: T, b: T) => boolean, 55 | ) => useStoreWithEqualityFn(originalStore, selector, equality); 56 | 57 | function useTemporalStore(): TemporalState; 58 | function useTemporalStore(selector: (state: TemporalState) => T): T; 59 | function useTemporalStore( 60 | selector: (state: TemporalState) => T, 61 | equality: (a: T, b: T) => boolean, 62 | ): T; 63 | function useTemporalStore( 64 | selector?: (state: TemporalState) => T, 65 | equality?: (a: T, b: T) => boolean, 66 | ) { 67 | return useStoreWithEqualityFn(originalStore.temporal, selector!, equality); 68 | } 69 | 70 | const isEmpty = (obj: object) => { 71 | for (const _ in obj) { 72 | return false; 73 | } 74 | return true; 75 | }; 76 | 77 | export default function Web() { 78 | const { 79 | count, 80 | increment, 81 | decrement, 82 | count2, 83 | increment2, 84 | decrement2, 85 | doNothing, 86 | } = useBaseStore((state) => state); 87 | const { futureStates, pastStates, undo, redo } = useTemporalStore( 88 | (state) => state, 89 | shallow, 90 | ); 91 | 92 | return ( 93 |
94 |

Web

95 |
96 | 97 | 98 | {count} 99 |
100 | 101 | 102 | {count2} 103 |
104 | 105 |
106 |

Future States

107 |
{JSON.stringify(futureStates)}
108 |

Previous States

109 |
{JSON.stringify(pastStates)}
110 | 111 | 112 |
113 |
114 |
115 | ); 116 | } 117 | -------------------------------------------------------------------------------- /examples/web/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash.throttle'; 2 | import { temporal, type TemporalState } from 'zundo'; 3 | import { createStore } from 'zustand'; 4 | import { shallow } from 'zustand/shallow'; 5 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 6 | 7 | interface MyState { 8 | count: number; 9 | increment: () => void; 10 | decrement: () => void; 11 | } 12 | 13 | const withZundo = temporal( 14 | (set) => ({ 15 | count: 0, 16 | increment: () => set((state) => ({ count: state.count + 1 })), 17 | decrement: () => set((state) => ({ count: state.count - 1 })), 18 | }), 19 | { 20 | handleSet: (handleSet) => 21 | throttle((state) => { 22 | console.info('handleSet called'); 23 | handleSet(state); 24 | }, 1000), 25 | }, 26 | ); 27 | 28 | const originalStore = createStore(withZundo); 29 | 30 | const useBaseStore = ( 31 | selector: (state: MyState) => T, 32 | equality?: (a: T, b: T) => boolean, 33 | ) => useStoreWithEqualityFn(originalStore, selector, equality); 34 | const useTemporalStore = ( 35 | selector: (state: TemporalState) => T, 36 | equality?: (a: T, b: T) => boolean, 37 | ) => useStoreWithEqualityFn(originalStore.temporal, selector, equality); 38 | 39 | export default function Web() { 40 | const { count, increment, decrement } = useBaseStore((state) => state); 41 | const { futureStates, pastStates, undo } = useTemporalStore( 42 | (state) => state, 43 | shallow, 44 | ); 45 | 46 | return ( 47 |
48 |

Web

49 |
50 | 51 | 52 | {count} 53 |
54 |

Future States

55 |
{JSON.stringify(futureStates)}
56 |

Previous States

57 |
{JSON.stringify(pastStates)}
58 | 59 |
60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /examples/web/pages/input.tsx: -------------------------------------------------------------------------------- 1 | import { temporal, type TemporalState } from 'zundo'; 2 | import { createStore } from 'zustand'; 3 | import { shallow } from 'zustand/shallow'; 4 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 5 | 6 | interface MyState { 7 | fontSize: number; 8 | changeFontSize: (fontSize: number) => void; 9 | } 10 | 11 | const withZundo = temporal((set) => ({ 12 | fontSize: 16, 13 | changeFontSize: (fontSize) => set({ fontSize }), 14 | })); 15 | 16 | const originalStore = createStore(withZundo); 17 | 18 | const useBaseStore = ( 19 | selector: (state: MyState) => T, 20 | equality?: (a: T, b: T) => boolean, 21 | ) => useStoreWithEqualityFn(originalStore, selector, equality); 22 | const useTemporalStore = ( 23 | selector: (state: TemporalState) => T, 24 | equality?: (a: T, b: T) => boolean, 25 | ) => useStoreWithEqualityFn(originalStore.temporal, selector, equality); 26 | 27 | export default function App() { 28 | const { fontSize, changeFontSize } = useBaseStore((state) => state); 29 | const { futureStates, pastStates, undo, resume, pause } = useTemporalStore( 30 | (state) => state, 31 | shallow, 32 | ); 33 | 34 | return ( 35 |
36 |

Web

37 |
38 | { 42 | changeFontSize(fontSize); 43 | pause(); 44 | }} 45 | onChange={(e) => changeFontSize(e.target.valueAsNumber)} 46 | onBlur={(e) => { 47 | resume(); 48 | changeFontSize(e.target.valueAsNumber); 49 | }} 50 | /> 51 | {fontSize} 52 |
53 |

Future States

54 |
{JSON.stringify(futureStates)}
55 |

Previous States

56 |
{JSON.stringify(pastStates)}
57 | 58 |
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /examples/web/pages/nested.tsx: -------------------------------------------------------------------------------- 1 | import { temporal, type TemporalState } from 'zundo'; 2 | import { 3 | create, 4 | type StateCreator, 5 | type StoreMutatorIdentifier, 6 | } from 'zustand'; 7 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 8 | 9 | interface MyState { 10 | incrementBears: () => void; 11 | incrementUntrackedValue: () => void; 12 | incrementAll: () => void; 13 | incrementSimple: () => void; 14 | simple: number; 15 | nested: { bears: number; untrackedValue: number }; 16 | } 17 | 18 | type HistoryTrackedState = Omit; 19 | 20 | // Note: This one is incomplete 21 | const middle = < 22 | T extends object, 23 | Mps extends [StoreMutatorIdentifier, unknown][] = [], 24 | Mcs extends [StoreMutatorIdentifier, unknown][] = [], 25 | >( 26 | config: StateCreator, 27 | ): StateCreator => { 28 | const foo: StateCreator = (_set, get, store) => { 29 | const set: typeof _set = (state, replace) => { 30 | if (state instanceof Function) { 31 | _set(mergeDeep(get(), state(get())), replace); 32 | return; 33 | } 34 | _set(mergeDeep(get(), state), replace); 35 | }; 36 | store.setState = set; 37 | return config(set, get, store); 38 | }; 39 | return foo; 40 | }; 41 | 42 | const useMyStore = create()( 43 | middle( 44 | temporal( 45 | (set) => ({ 46 | simple: 0, 47 | nested: { bears: 0, untrackedValue: 0 }, 48 | incrementBears: () => 49 | set(({ nested: { bears, untrackedValue } }) => ({ 50 | nested: { bears: bears + 1, untrackedValue }, 51 | })), 52 | incrementAll: () => 53 | set(({ nested: { bears, untrackedValue }, simple }) => ({ 54 | nested: { bears: bears + 1, untrackedValue: untrackedValue + 1 }, 55 | simple: simple + 1, 56 | })), 57 | incrementUntrackedValue: () => 58 | set(({ nested: { bears, untrackedValue } }) => ({ 59 | nested: { bears: bears, untrackedValue: untrackedValue + 1 }, 60 | })), 61 | incrementSimple: () => set(({ simple }) => ({ simple: simple + 1 })), 62 | }), 63 | { 64 | partialize: (state): HistoryTrackedState => { 65 | const { nested } = state; 66 | // TODO: recursive partial 67 | return { nested: { bears: nested.bears } }; 68 | }, 69 | }, 70 | ), 71 | ), 72 | ); 73 | 74 | /** 75 | * Performs a deep merge of objects and returns new object. Does not modify 76 | * objects (immutable) and merges arrays via concatenation. 77 | * 78 | * @param {...object} objects - Objects to merge 79 | * @returns {object} New object with merged key/values 80 | * Citation: {@link https://stackoverflow.com/a/48218209/9931154 Stack Overflow Reference} 81 | */ 82 | function mergeDeep(...objects: any[]) { 83 | const isObject = (obj: unknown) => obj && typeof obj === 'object'; 84 | 85 | return objects.reduce((prev, obj) => { 86 | Object.keys(obj).forEach((key) => { 87 | const pVal = prev[key]; 88 | const oVal = obj[key]; 89 | 90 | if (Array.isArray(pVal) && Array.isArray(oVal)) { 91 | prev[key] = pVal.concat(...oVal); 92 | } else if (isObject(pVal) && isObject(oVal)) { 93 | prev[key] = mergeDeep(pVal, oVal); 94 | } else { 95 | prev[key] = oVal; 96 | } 97 | }); 98 | 99 | return prev; 100 | }, {}); 101 | } 102 | 103 | const useTemporalStore = ( 104 | selector: (state: TemporalState) => T, 105 | equality?: (a: T, b: T) => boolean, 106 | ) => useStoreWithEqualityFn(useMyStore.temporal, selector, equality); 107 | 108 | const App = () => { 109 | const store = useMyStore(); 110 | const { 111 | simple, 112 | nested, 113 | incrementUntrackedValue, 114 | incrementAll, 115 | incrementBears, 116 | incrementSimple, 117 | } = store; 118 | const { undo, redo, clear, futureStates, pastStates } = useTemporalStore( 119 | (state) => state, 120 | ); 121 | 122 | return ( 123 |
124 |

125 | {' '} 126 | 127 | 🐻 128 | {' '} 129 | 130 | ♻️ 131 | {' '} 132 | Zundo! 133 |

134 |

135 | With config options:
136 | partialize, handleSet, equality 137 |

138 |

The throttle value is set to 500ms.

139 |

untrackedValue is not tracked in history (partialize)

140 |

equality function is fast-deep-equal

141 |

142 | Note that clicking the button that increments untrackedValue prior to 143 | incrementing bears results in state history of bears not being tracked 144 |

145 | 146 |

147 | 148 |

149 | 150 |

151 | 152 |

153 | 154 | 155 | 156 |

157 | past states: {JSON.stringify(pastStates)} 158 |
159 | future states: {JSON.stringify(futureStates)} 160 |
161 | current state: {JSON.stringify(store)} 162 |
163 |
164 | nested.bears: {nested.bears} 165 |
166 | nested.untrackedValue: {nested.untrackedValue} 167 |
168 | simple: {simple} 169 |
170 |
171 | ); 172 | }; 173 | 174 | export default App; 175 | -------------------------------------------------------------------------------- /examples/web/pages/options.tsx: -------------------------------------------------------------------------------- 1 | import { temporal, type TemporalState } from 'zundo'; 2 | import { create } from 'zustand'; 3 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 4 | import deepEqual from 'fast-deep-equal'; 5 | import throttle from 'just-throttle'; 6 | import './styles.css'; 7 | 8 | interface MyState { 9 | bears: number; 10 | untrackedValue: number; 11 | increment: () => void; 12 | decrement: () => void; 13 | incrementUntrackedValue: () => void; 14 | } 15 | 16 | type HistoryTrackedState = Omit; 17 | 18 | const useMyStore = create()( 19 | temporal( 20 | (set) => ({ 21 | bears: 0, 22 | untrackedValue: 0, 23 | increment: () => set((state) => ({ bears: state.bears + 1 })), 24 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 25 | incrementUntrackedValue: () => 26 | set((state) => ({ untrackedValue: state.untrackedValue + 1 })), 27 | }), 28 | { 29 | equality: deepEqual, 30 | handleSet: (handleSet) => 31 | throttle((state) => { 32 | handleSet(state); 33 | }, 500), 34 | partialize: (state): HistoryTrackedState => { 35 | const { untrackedValue, ...trackedValues } = state; 36 | return { ...trackedValues }; 37 | }, 38 | }, 39 | ), 40 | ); 41 | 42 | const useTemporalStore = ( 43 | selector: (state: TemporalState) => T, 44 | equality?: (a: T, b: T) => boolean, 45 | ) => useStoreWithEqualityFn(useMyStore.temporal, selector, equality); 46 | 47 | const App = () => { 48 | const store = useMyStore(); 49 | const { bears, increment, decrement, incrementUntrackedValue } = store; 50 | const { undo, redo, clear, futureStates, pastStates } = useTemporalStore( 51 | (state) => state, 52 | ); 53 | 54 | return ( 55 |
56 |

57 | {' '} 58 | 59 | 🐻 60 | {' '} 61 | 62 | ♻️ 63 | {' '} 64 | Zundo! 65 |

66 |

67 | With config options:
68 | partialize, handleSet, equality 69 |

70 |

The throttle value is set to 500ms.

71 |

untrackedValue is not tracked in history (partialize)

72 |

equality function is fast-deep-equal

73 |

74 | Note that clicking the button that increments untrackedValue prior to 75 | incrementing bears results in state history of bears not being tracked 76 |

77 | 85 |

86 | 94 |

95 | 96 |
97 | 98 | 99 | 100 |

101 | past states: {JSON.stringify(pastStates)} 102 |
103 | future states: {JSON.stringify(futureStates)} 104 |
105 | current state: {JSON.stringify(store)} 106 |
107 |
108 | bears: {bears} 109 |
110 |
111 | ); 112 | }; 113 | 114 | export default App; 115 | -------------------------------------------------------------------------------- /examples/web/pages/persist.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { create, useStore } from 'zustand'; 3 | import { temporal } from 'zundo'; 4 | import { persist, type PersistOptions } from 'zustand/middleware'; 5 | import { immer } from 'zustand/middleware/immer'; 6 | import dynamic from 'next/dynamic'; 7 | import merge from 'lodash.merge'; 8 | 9 | interface Store { 10 | count: number; 11 | inc: () => void; 12 | dec: () => void; 13 | } 14 | 15 | const persistOptions: PersistOptions = { 16 | name: 'some-store', 17 | }; 18 | 19 | const useMyStore = create()( 20 | persist( 21 | temporal( 22 | immer((set) => ({ 23 | count: 0, 24 | inc: () => 25 | set((state) => { 26 | state.count++; 27 | }), 28 | dec: () => 29 | set((state) => { 30 | state.count--; 31 | }), 32 | })), 33 | { 34 | limit: 5, 35 | wrapTemporal: (store) => 36 | persist(store, { 37 | name: 'some-store-temporal', 38 | merge: (persistedState, currentState) => 39 | merge(currentState, persistedState), 40 | }), 41 | }, 42 | ), 43 | persistOptions, 44 | ), 45 | ); 46 | 47 | const useTemporalStore = () => useStore(useMyStore.temporal); 48 | 49 | export const Persist = dynamic( 50 | Promise.resolve(() => { 51 | const state = useMyStore(); 52 | const temporalState = useTemporalStore(); 53 | 54 | const localStorageStateOnLoad = useMemo( 55 | () => localStorage.getItem('some-store') ?? '{}', 56 | [], 57 | ); 58 | const localStorageTemporalStateOnLoad = useMemo( 59 | () => localStorage.getItem('some-store-temporal') ?? '{}', 60 | [], 61 | ); 62 | 63 | return ( 64 |
65 |

Count: {state.count}

66 | 67 | 68 | 69 |
70 |
71 |

Current state

72 |
{JSON.stringify(state, null, 2)}
73 |
74 | 75 |
76 |

Previous states

77 |
{JSON.stringify(temporalState, null, 2)}
78 |
79 |
80 | 81 |
82 |
83 |

Local storage state on load

84 |
 85 |               {JSON.stringify(JSON.parse(localStorageStateOnLoad), null, 2)}
 86 |             
87 |
88 | 89 |
90 |

Local storage temporal state on load

91 |
 92 |               {JSON.stringify(
 93 |                 JSON.parse(localStorageTemporalStateOnLoad),
 94 |                 null,
 95 |                 2,
 96 |               )}
 97 |             
98 |
99 |
100 |
101 | ); 102 | }), 103 | { ssr: false }, 104 | ); 105 | 106 | export default Persist; 107 | -------------------------------------------------------------------------------- /examples/web/pages/reactive.tsx: -------------------------------------------------------------------------------- 1 | import { type TemporalState, temporal } from 'zundo'; 2 | import { type StoreApi, create } from 'zustand'; 3 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 4 | 5 | interface MyState { 6 | bears: number; 7 | increment: () => void; 8 | decrement: () => void; 9 | } 10 | 11 | const useMyStore = create( 12 | temporal((set) => ({ 13 | bears: 0, 14 | increment: () => set((state) => ({ bears: state.bears + 1 })), 15 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 16 | })), 17 | ); 18 | 19 | type ExtractState = S extends { 20 | getState: () => infer T; 21 | } 22 | ? T 23 | : never; 24 | type ReadonlyStoreApi = Pick, 'getState' | 'subscribe'>; 25 | type WithReact> = S & { 26 | getServerState?: () => ExtractState; 27 | }; 28 | 29 | const useTemporalStore = < 30 | S extends WithReact>>, 31 | U, 32 | >( 33 | selector: (state: ExtractState) => U, 34 | equality?: (a: U, b: U) => boolean, 35 | ): U => { 36 | const state = useStoreWithEqualityFn( 37 | useMyStore.temporal as any, 38 | selector, 39 | equality, 40 | ); 41 | return state; 42 | }; 43 | 44 | const HistoryBar = () => { 45 | const futureStates = useTemporalStore((state) => state.futureStates); 46 | const pastStates = useTemporalStore((state) => state.pastStates); 47 | return ( 48 |
49 | past states: {JSON.stringify(pastStates)} 50 |
51 | future states: {JSON.stringify(futureStates)} 52 |
53 |
54 | ); 55 | }; 56 | 57 | const UndoBar = () => { 58 | const { undo, redo } = useTemporalStore((state) => ({ 59 | undo: state.undo, 60 | redo: state.redo, 61 | })); 62 | return ( 63 |
64 | 65 | 66 |
67 | ); 68 | }; 69 | 70 | const StateBar = () => { 71 | const store = useMyStore(); 72 | const { bears, increment, decrement } = store; 73 | return ( 74 |
75 | current state: {JSON.stringify(store)} 76 |
77 |
78 | bears: {bears} 79 |
80 | 81 | 82 |
83 | ); 84 | }; 85 | 86 | const App = () => { 87 | return ( 88 |
89 |

90 | {' '} 91 | 92 | 🐻 93 | {' '} 94 | 95 | ♻️ 96 | {' '} 97 | Zundo! 98 |

99 | 100 |
101 | 102 | 103 |
104 | ); 105 | }; 106 | 107 | export default App; 108 | -------------------------------------------------------------------------------- /examples/web/pages/separate.tsx: -------------------------------------------------------------------------------- 1 | import { temporal } from 'zundo'; 2 | import { create, useStore } from 'zustand'; 3 | 4 | interface MyState { 5 | bears: number; 6 | bees: number; 7 | increment: () => void; 8 | decrement: () => void; 9 | incrementBees: () => void; 10 | decrementBees: () => void; 11 | } 12 | 13 | const useMyStore = create( 14 | temporal( 15 | (set) => ({ 16 | bears: 0, 17 | bees: 10, 18 | increment: () => set((state) => ({ bears: state.bears + 1 })), 19 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 20 | incrementBees: () => set((state) => ({ bees: state.bees + 1 })), 21 | decrementBees: () => set((state) => ({ bees: state.bees - 1 })), 22 | }), 23 | { 24 | pastStates: [{ bees: 20 }, { bees: 30 }], 25 | }, 26 | ), 27 | ); 28 | const useTemporalStore = () => useStore(useMyStore.temporal); 29 | 30 | const UndoBar = () => { 31 | const { undo, redo, futureStates, pastStates } = useTemporalStore(); 32 | return ( 33 |
34 | past states: {JSON.stringify(pastStates)} 35 |
36 | future states: {JSON.stringify(futureStates)} 37 |
38 | 41 | 44 |
45 | ); 46 | }; 47 | 48 | const StateBear = () => { 49 | const store = useMyStore((state) => ({ 50 | bears: state.bears, 51 | increment: state.increment, 52 | decrement: state.decrement, 53 | })); 54 | const { bears, increment, decrement } = store; 55 | return ( 56 |
57 | current state: {JSON.stringify(store)} 58 |
59 |
60 | bears: {bears} 61 |
62 | 63 | 64 |
65 | ); 66 | }; 67 | 68 | const StateBee = () => { 69 | const store = useMyStore(); 70 | console.log(store); 71 | const { bees, increment, decrement } = store; 72 | return ( 73 |
74 | current state: {JSON.stringify(store)} 75 |
76 |
77 | bees: {bees} 78 |
79 | 80 | 81 |
82 | ); 83 | }; 84 | 85 | const App = () => { 86 | return ( 87 |
88 |

89 | {' '} 90 | 91 | 🐻 92 | {' '} 93 | 94 | ♻️ 95 | {' '} 96 | Zundo! 97 |

98 | 99 | 100 |
101 | 102 |
103 | ); 104 | }; 105 | 106 | export default App; 107 | -------------------------------------------------------------------------------- /examples/web/pages/setstate.tsx: -------------------------------------------------------------------------------- 1 | import { create, useStore } from 'zustand'; 2 | import { temporal } from 'zundo'; 3 | 4 | interface ExampleData { 5 | key1: boolean; 6 | key2: number; 7 | } 8 | 9 | const store = create()( 10 | temporal(() => ({ 11 | key1: false as boolean, 12 | key2: 32, 13 | })), 14 | ); 15 | 16 | const useTemporal = () => useStore(store.temporal); 17 | 18 | const App = () => { 19 | const data = store(); 20 | const { undo } = useTemporal(); 21 | 22 | return ( 23 | <> 24 | 31 | 38 | 39 |
40 | 41 |

Data

42 |

{JSON.stringify(data, undefined, 2)}

43 | 44 | 45 | 46 | ); 47 | }; 48 | export default App; 49 | -------------------------------------------------------------------------------- /examples/web/pages/slices.tsx: -------------------------------------------------------------------------------- 1 | import type { StateCreator } from 'zustand'; 2 | import type { TemporalState } from 'zundo'; 3 | import { temporal } from 'zundo'; 4 | import { create } from 'zustand'; 5 | import { useShallow } from 'zustand/shallow'; 6 | import { useStoreWithEqualityFn } from 'zustand/traditional'; 7 | 8 | interface BearSlice { 9 | bears: number 10 | addBear: () => void 11 | eatFish: () => void 12 | } 13 | 14 | interface FishSlice { 15 | fishes: number 16 | addFish: () => void 17 | } 18 | 19 | const createBearSlice: StateCreator< 20 | BearSlice & FishSlice, 21 | [], 22 | [], 23 | BearSlice 24 | > = (set) => ({ 25 | bears: 0, 26 | addBear: () => set((state) => ({ bears: state.bears + 1 })), 27 | removeBear: () => set((state) => ({ bears: state.bears - 1 })), 28 | eatFish: () => set((state) => ({ fishes: state.fishes - 1 })), 29 | }) 30 | 31 | const createFishSlice: StateCreator< 32 | BearSlice & FishSlice, 33 | [], 34 | [], 35 | FishSlice 36 | > = (set) => ({ 37 | fishes: 0, 38 | addFish: () => set((state) => ({ fishes: state.fishes + 1 })), 39 | }) 40 | 41 | const useSharedStore = create()( 42 | temporal( 43 | (...a) => ({ 44 | ...createBearSlice(...a), 45 | ...createFishSlice(...a), 46 | }), 47 | { 48 | limit: 10 49 | } 50 | ) 51 | ) 52 | 53 | function useTemporalStore(): TemporalState; 54 | function useTemporalStore( 55 | selector: (state: TemporalState) => T 56 | ): T; 57 | function useTemporalStore( 58 | selector: (state: TemporalState) => T, 59 | equality: (a: T, b: T) => boolean 60 | ): T; 61 | function useTemporalStore( 62 | selector?: (state: TemporalState) => T, 63 | equality?: (a: T, b: T) => boolean 64 | ) { 65 | return useStoreWithEqualityFn(useSharedStore.temporal, selector!, equality); 66 | } 67 | 68 | const UndoBar = () => { 69 | const { undo, redo, pastStates, futureStates } = useTemporalStore( 70 | (state) => ({ 71 | undo: state.undo, 72 | redo: state.redo, 73 | pastStates: state.pastStates, 74 | futureStates: state.futureStates, 75 | }) 76 | ); 77 | 78 | return ( 79 |
80 | past states: {JSON.stringify(pastStates)} 81 |
82 | future states: {JSON.stringify(futureStates)} 83 |
84 | 87 | 90 |
91 | ); 92 | }; 93 | 94 | const BearState = () => { 95 | const { bears, addBear, eatFish } = useSharedStore(useShallow((state) => ({ 96 | bears: state.bears, 97 | addBear: state.addBear, 98 | eatFish: state.eatFish, 99 | }))); 100 | 101 | return ( 102 |
103 | bears: {bears} 104 |
105 | 106 | 107 |
108 | ); 109 | }; 110 | 111 | const FishState = () => { 112 | const { fishes, addFish } = useSharedStore(useShallow((state) => ({ 113 | fishes: state.fishes, 114 | addFish: state.addFish, 115 | }))); 116 | 117 | return ( 118 |
119 | fishes: {fishes} 120 |
121 | 122 |
123 | ); 124 | }; 125 | 126 | const App = () => { 127 | return ( 128 |
129 |

130 | {' '} 131 | 132 | 🐻 133 | {' '} 134 | 135 | ♻️ 136 | {' '} 137 | Zundo! 138 |

139 | 140 | 141 |
142 | 143 |
144 | ); 145 | }; 146 | 147 | export default App; 148 | -------------------------------------------------------------------------------- /examples/web/pages/subscribeWithSelector.tsx: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { subscribeWithSelector } from 'zustand/middleware'; 3 | import { temporal } from 'zundo'; 4 | 5 | interface ExampleData { 6 | key1: boolean; 7 | key2: number; 8 | } 9 | 10 | const store = create()( 11 | subscribeWithSelector( 12 | temporal(() => ({ 13 | // TODO: find a way to remove this type cast. 14 | key1: false as boolean, 15 | key2: 32, 16 | })), 17 | ), 18 | ); 19 | 20 | export default function Dummy() { 21 | return <>; 22 | } 23 | -------------------------------------------------------------------------------- /examples/web/pages/throttle.tsx: -------------------------------------------------------------------------------- 1 | import { temporal } from 'zundo'; 2 | import { create, useStore } from 'zustand'; 3 | import throttle from 'just-throttle'; 4 | 5 | interface MyState { 6 | bears: number; 7 | bees: number; 8 | increment: () => void; 9 | decrement: () => void; 10 | incrementBees: () => void; 11 | decrementBees: () => void; 12 | } 13 | 14 | const useMyStore = create( 15 | temporal( 16 | (set) => ({ 17 | bears: 0, 18 | bees: 10, 19 | increment: () => set((state) => ({ bears: state.bears + 1 })), 20 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 21 | incrementBees: () => set((state) => ({ bees: state.bees + 1 })), 22 | decrementBees: () => set((state) => ({ bees: state.bees - 1 })), 23 | }), 24 | { 25 | wrapTemporal: (config) => { 26 | const thing: typeof config = (_set, get, store) => { 27 | const set: typeof _set = throttle((...args) => { 28 | console.info('handleSet called'); 29 | console.log( 30 | 'calling wrapped setter', 31 | JSON.stringify(args[0], null, 2), 32 | ); 33 | _set(...(args as Parameters)); 34 | }, 1000); 35 | return config(set, get, store); 36 | }; 37 | return thing; 38 | }, 39 | }, 40 | ), 41 | ); 42 | const useTemporalStore = () => useStore(useMyStore.temporal); 43 | 44 | const UndoBar = () => { 45 | const { undo, redo, futureStates, pastStates } = useTemporalStore(); 46 | return ( 47 |
48 | past states: {JSON.stringify(pastStates)} 49 |
50 | future states: {JSON.stringify(futureStates)} 51 |
52 | 55 | 58 |
59 | ); 60 | }; 61 | 62 | const StateBear = () => { 63 | const store = useMyStore((state) => ({ 64 | bears: state.bears, 65 | increment: state.increment, 66 | decrement: state.decrement, 67 | })); 68 | const { bears, increment, decrement } = store; 69 | return ( 70 |
71 | current state: {JSON.stringify(store)} 72 |
73 |
74 | bears: {bears} 75 |
76 | 77 | 78 |
79 | ); 80 | }; 81 | 82 | const StateBee = () => { 83 | const store = useMyStore(); 84 | console.log(store); 85 | const { bees, increment, decrement } = store; 86 | return ( 87 |
88 | current state: {JSON.stringify(store)} 89 |
90 |
91 | bees: {bees} 92 |
93 | 94 | 95 |
96 | ); 97 | }; 98 | 99 | const App = () => { 100 | return ( 101 |
102 |

103 | {' '} 104 | 105 | 🐻 106 | {' '} 107 | 108 | ♻️ 109 | {' '} 110 | Zundo! 111 |

112 | 113 | 114 |
115 | 116 |
117 | ); 118 | }; 119 | 120 | export default App; 121 | -------------------------------------------------------------------------------- /examples/web/pages/wrapped.tsx: -------------------------------------------------------------------------------- 1 | import { temporal } from 'zundo'; 2 | import { create, useStore } from 'zustand'; 3 | 4 | interface MyState { 5 | bears: number; 6 | bees: number; 7 | increment: () => void; 8 | decrement: () => void; 9 | incrementBees: () => void; 10 | decrementBees: () => void; 11 | } 12 | 13 | const useMyStore = create( 14 | temporal( 15 | (set) => ({ 16 | bears: 0, 17 | bees: 10, 18 | increment: () => set((state) => ({ bears: state.bears + 1 })), 19 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 20 | incrementBees: () => set((state) => ({ bees: state.bees + 1 })), 21 | decrementBees: () => set((state) => ({ bees: state.bees - 1 })), 22 | }), 23 | { 24 | wrapTemporal: (config) => { 25 | const thing: typeof config = (_set, get, store) => { 26 | const set: typeof _set = (...args) => { 27 | console.info('handleSet called'); 28 | console.log( 29 | 'calling wrapped setter', 30 | JSON.stringify(args[0], null, 2), 31 | ); 32 | _set(...(args as Parameters)); 33 | }; 34 | return config(set, get, store); 35 | }; 36 | return thing; 37 | }, 38 | }, 39 | ), 40 | ); 41 | const useTemporalStore = () => useStore(useMyStore.temporal); 42 | 43 | const UndoBar = () => { 44 | const { undo, redo, futureStates, pastStates } = useTemporalStore(); 45 | return ( 46 |
47 | past states: {JSON.stringify(pastStates)} 48 |
49 | future states: {JSON.stringify(futureStates)} 50 |
51 | 54 | 57 |
58 | ); 59 | }; 60 | 61 | const StateBear = () => { 62 | const store = useMyStore((state) => ({ 63 | bears: state.bears, 64 | increment: state.increment, 65 | decrement: state.decrement, 66 | })); 67 | const { bears, increment, decrement } = store; 68 | return ( 69 |
70 | current state: {JSON.stringify(store)} 71 |
72 |
73 | bears: {bears} 74 |
75 | 76 | 77 |
78 | ); 79 | }; 80 | 81 | const StateBee = () => { 82 | const store = useMyStore(); 83 | console.log(store); 84 | const { bees, increment, decrement } = store; 85 | return ( 86 |
87 | current state: {JSON.stringify(store)} 88 |
89 |
90 | bees: {bees} 91 |
92 | 93 | 94 |
95 | ); 96 | }; 97 | 98 | const App = () => { 99 | return ( 100 |
101 |

102 | {' '} 103 | 104 | 🐻 105 | {' '} 106 | 107 | ♻️ 108 | {' '} 109 | Zundo! 110 |

111 | 112 | 113 |
114 | 115 |
116 | ); 117 | }; 118 | 119 | export default App; 120 | -------------------------------------------------------------------------------- /examples/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 4 | "exclude": ["node_modules"], 5 | "compilerOptions": { 6 | "jsx": "preserve", 7 | "rootDir": ".", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "module": "esnext", 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zundo", 3 | "version": "2.3.0", 4 | "private": false, 5 | "description": "🍜 undo/redo middleware for zustand", 6 | "keywords": [ 7 | "undo", 8 | "redo", 9 | "history", 10 | "middleware", 11 | "zustand", 12 | "react" 13 | ], 14 | "homepage": "https://github.com/charkour/zundo", 15 | "bugs": { 16 | "url": "https://github.com/charkour/zundo/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/charkour/zundo.git" 21 | }, 22 | "funding": { 23 | "type": "individual", 24 | "url": "https://github.com/sponsors/charkour" 25 | }, 26 | "license": "MIT", 27 | "author": "Charles Kornoelje", 28 | "type": "module", 29 | "exports": { 30 | "import": { 31 | "types": "./dist/index.d.ts", 32 | "default": "./dist/index.js" 33 | }, 34 | "require": { 35 | "types": "./dist/index.d.cts", 36 | "default": "./dist/index.cjs" 37 | } 38 | }, 39 | "main": "./dist/index.cjs", 40 | "module": "./dist/index.js", 41 | "types": "./dist/index.d.ts", 42 | "files": [ 43 | "dist", 44 | "package.json" 45 | ], 46 | "scripts": { 47 | "build": "tsup", 48 | "dev": "tsup --watch", 49 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 50 | "size": "pnpm --filter size size", 51 | "test": "pnpm --filter tests test", 52 | "test:ci": "pnpm --filter tests test:ci" 53 | }, 54 | "devDependencies": { 55 | "prettier": "3.4.2", 56 | "tsup": "8.3.5", 57 | "typescript": "5.7.3", 58 | "zustand": "5.0.3" 59 | }, 60 | "peerDependencies": { 61 | "zustand": "^4.3.0 || ^5.0.0" 62 | }, 63 | "peerDependenciesMeta": { 64 | "zustand": { 65 | "optional": false 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "examples/*" 3 | - "tests" 4 | - "size" 5 | -------------------------------------------------------------------------------- /size/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "size", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "size": "size-limit" 7 | }, 8 | "devDependencies": { 9 | "@size-limit/preset-small-lib": "11.1.6", 10 | "size-limit": "11.1.6" 11 | }, 12 | "size-limit": [ 13 | { 14 | "limit": "700 B", 15 | "path": "../dist/index.js", 16 | "gzip": true, 17 | "ignore": [ 18 | "react", 19 | "react-dom", 20 | "zustand" 21 | ] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from 'zustand'; 2 | import { temporalStateCreator } from './temporal'; 3 | import type { 4 | StateCreator, 5 | StoreMutatorIdentifier, 6 | Mutate, 7 | StoreApi, 8 | } from 'zustand'; 9 | import type { 10 | TemporalState, 11 | _TemporalState, 12 | Write, 13 | ZundoOptions, 14 | } from './types'; 15 | 16 | type Zundo = < 17 | TState, 18 | Mps extends [StoreMutatorIdentifier, unknown][] = [], 19 | Mcs extends [StoreMutatorIdentifier, unknown][] = [], 20 | UState = TState, 21 | >( 22 | config: StateCreator, 23 | options?: ZundoOptions, 24 | ) => StateCreator< 25 | TState, 26 | Mps, 27 | [['temporal', StoreApi>], ...Mcs] 28 | >; 29 | 30 | declare module 'zustand/vanilla' { 31 | interface StoreMutators { 32 | temporal: Write; 33 | } 34 | } 35 | 36 | export const temporal = (( 37 | config: StateCreator, 38 | options?: ZundoOptions, 39 | ): StateCreator => { 40 | const configWithTemporal = ( 41 | set: StoreApi['setState'], 42 | get: StoreApi['getState'], 43 | store: Mutate< 44 | StoreApi, 45 | [['temporal', StoreApi>]] 46 | >, 47 | ) => { 48 | store.temporal = createStore( 49 | options?.wrapTemporal?.(temporalStateCreator(set, get, options)) || 50 | temporalStateCreator(set, get, options), 51 | ); 52 | 53 | const curriedHandleSet = 54 | options?.handleSet?.( 55 | (store.temporal.getState() as _TemporalState) 56 | ._handleSet as StoreApi['setState'], 57 | ) || (store.temporal.getState() as _TemporalState)._handleSet; 58 | 59 | const temporalHandleSet = (pastState: TState) => { 60 | if (!store.temporal.getState().isTracking) return; 61 | 62 | const currentState = options?.partialize?.(get()) || get(); 63 | const deltaState = options?.diff?.(pastState, currentState); 64 | if ( 65 | // Don't call handleSet if state hasn't changed, as determined by diff fn or equality fn 66 | !( 67 | // If the user has provided a diff function but nothing has been changed, deltaState will be null 68 | ( 69 | deltaState === null || 70 | // If the user has provided an equality function, use it 71 | options?.equality?.(pastState, currentState) 72 | ) 73 | ) 74 | ) { 75 | curriedHandleSet( 76 | pastState, 77 | undefined as unknown as Parameters[1], 78 | currentState, 79 | deltaState, 80 | ); 81 | } 82 | }; 83 | 84 | const setState = store.setState; 85 | // Modify the setState function to call the userlandSet function 86 | store.setState = (...args) => { 87 | // Get most up to date state. The state from the callback might be a partial state. 88 | // The order of the get() and set() calls is important here. 89 | const pastState = options?.partialize?.(get()) || get(); 90 | setState(...(args as Parameters)); 91 | temporalHandleSet(pastState); 92 | }; 93 | 94 | return config( 95 | // Modify the set function to call the userlandSet function 96 | (...args) => { 97 | // Get most up-to-date state. The state from the callback might be a partial state. 98 | // The order of the get() and set() calls is important here. 99 | const pastState = options?.partialize?.(get()) || get(); 100 | set(...(args as Parameters)); 101 | temporalHandleSet(pastState); 102 | }, 103 | get, 104 | store, 105 | ); 106 | }; 107 | return configWithTemporal as StateCreator; 108 | }) as unknown as Zundo; 109 | 110 | export type { ZundoOptions, Zundo, TemporalState }; 111 | -------------------------------------------------------------------------------- /src/temporal.ts: -------------------------------------------------------------------------------- 1 | import type { StateCreator, StoreApi } from 'zustand'; 2 | import type { _TemporalState, ZundoOptions } from './types'; 3 | 4 | export const temporalStateCreator = ( 5 | userSet: StoreApi['setState'], 6 | userGet: StoreApi['getState'], 7 | options?: ZundoOptions, 8 | ) => { 9 | const stateCreator: StateCreator<_TemporalState, [], []> = ( 10 | set, 11 | get, 12 | ) => { 13 | return { 14 | pastStates: options?.pastStates || [], 15 | futureStates: options?.futureStates || [], 16 | undo: (steps = 1) => { 17 | if (get().pastStates.length) { 18 | // userGet must be called before userSet 19 | const currentState = options?.partialize?.(userGet()) || userGet(); 20 | 21 | const statesToApply = get().pastStates.splice(-steps, steps); 22 | 23 | // If there is length, we know that statesToApply is not empty 24 | const nextState = statesToApply.shift()!; 25 | userSet(nextState); 26 | set({ 27 | pastStates: get().pastStates, 28 | futureStates: get().futureStates.concat( 29 | options?.diff?.(currentState, nextState) || currentState, 30 | statesToApply.reverse(), 31 | ), 32 | }); 33 | } 34 | }, 35 | redo: (steps = 1) => { 36 | if (get().futureStates.length) { 37 | // userGet must be called before userSet 38 | const currentState = options?.partialize?.(userGet()) || userGet(); 39 | 40 | const statesToApply = get().futureStates.splice(-steps, steps); 41 | 42 | // If there is length, we know that statesToApply is not empty 43 | const nextState = statesToApply.shift()!; 44 | userSet(nextState); 45 | set({ 46 | pastStates: get().pastStates.concat( 47 | options?.diff?.(currentState, nextState) || currentState, 48 | statesToApply.reverse(), 49 | ), 50 | futureStates: get().futureStates, 51 | }); 52 | } 53 | }, 54 | clear: () => set({ pastStates: [], futureStates: [] }), 55 | isTracking: true, 56 | pause: () => set({ isTracking: false }), 57 | resume: () => set({ isTracking: true }), 58 | setOnSave: (_onSave) => set({ _onSave }), 59 | // Internal properties 60 | _onSave: options?.onSave, 61 | _handleSet: (pastState, replace, currentState, deltaState) => { 62 | // This naively assumes that only one new state can be added at a time 63 | if (options?.limit && get().pastStates.length >= options?.limit) { 64 | get().pastStates.shift(); 65 | } 66 | 67 | get()._onSave?.(pastState, currentState); 68 | set({ 69 | pastStates: get().pastStates.concat(deltaState || pastState), 70 | futureStates: [], 71 | }); 72 | }, 73 | }; 74 | }; 75 | 76 | // Cast to a version of the store that does not include "temporal" addition 77 | return stateCreator as StateCreator<_TemporalState, [], []>; 78 | }; 79 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { StoreApi, StoreMutatorIdentifier, StateCreator } from 'zustand'; 2 | 3 | type onSave = 4 | | ((pastState: TState, currentState: TState) => void) 5 | | undefined; 6 | 7 | export interface _TemporalState { 8 | pastStates: Partial[]; 9 | futureStates: Partial[]; 10 | 11 | undo: (steps?: number) => void; 12 | redo: (steps?: number) => void; 13 | clear: () => void; 14 | 15 | isTracking: boolean; 16 | pause: () => void; 17 | resume: () => void; 18 | 19 | setOnSave: (onSave: onSave) => void; 20 | _onSave: onSave; 21 | _handleSet: ( 22 | pastState: TState, 23 | // `replace` will likely be deprecated and removed in the future 24 | replace: Parameters['setState']>[1], 25 | currentState: TState, 26 | deltaState?: Partial | null, 27 | ) => void; 28 | } 29 | 30 | export interface ZundoOptions { 31 | partialize?: (state: TState) => PartialTState; 32 | limit?: number; 33 | equality?: (pastState: PartialTState, currentState: PartialTState) => boolean; 34 | diff?: ( 35 | pastState: Partial, 36 | currentState: Partial, 37 | ) => Partial | null; 38 | onSave?: onSave; 39 | handleSet?: (handleSet: StoreApi['setState']) => ( 40 | pastState: Parameters['setState']>[0], 41 | // `replace` will likely be deprecated and removed in the future 42 | replace: Parameters['setState']>[1], 43 | currentState: PartialTState, 44 | deltaState?: Partial | null, 45 | ) => void; 46 | pastStates?: Partial[]; 47 | futureStates?: Partial[]; 48 | wrapTemporal?: ( 49 | storeInitializer: StateCreator< 50 | _TemporalState, 51 | [StoreMutatorIdentifier, unknown][], 52 | [] 53 | >, 54 | ) => StateCreator< 55 | _TemporalState, 56 | [StoreMutatorIdentifier, unknown][], 57 | [StoreMutatorIdentifier, unknown][] 58 | >; 59 | } 60 | 61 | export type Write = Omit & U; 62 | 63 | export type TemporalState = Omit< 64 | _TemporalState, 65 | '_onSave' | '_handleSet' 66 | >; 67 | -------------------------------------------------------------------------------- /tests/__mocks__/zustand/index.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/pmndrs/zustand/blob/main/docs/guides/testing.md 2 | import * as zustand from 'zustand'; 3 | import { act } from '@testing-library/react'; 4 | import { afterEach, vi } from 'vitest'; 5 | 6 | const { create: actualCreate, createStore: actualCreateStore } = 7 | await vi.importActual('zustand'); 8 | 9 | // a variable to hold reset functions for all stores declared in the app 10 | export const storeResetFns = new Set<() => void>(); 11 | 12 | const createUncurried = (stateCreator: zustand.StateCreator) => { 13 | const store = actualCreate(stateCreator); 14 | const initialState = store.getInitialState(); 15 | storeResetFns.add(() => { 16 | store.setState(initialState, true); 17 | }); 18 | return store; 19 | }; 20 | 21 | // when creating a store, we get its initial state, create a reset function and add it in the set 22 | export const create = ((stateCreator: zustand.StateCreator) => { 23 | // to support curried version of create 24 | return typeof stateCreator === 'function' 25 | ? createUncurried(stateCreator) 26 | : createUncurried; 27 | }) as typeof zustand.create; 28 | 29 | const createStoreUncurried = (stateCreator: zustand.StateCreator) => { 30 | const store = actualCreateStore(stateCreator); 31 | const initialState = store.getInitialState(); 32 | storeResetFns.add(() => { 33 | store.setState(initialState, true); 34 | }); 35 | return store; 36 | }; 37 | 38 | // when creating a store, we get its initial state, create a reset function and add it in the set 39 | export const createStore = ((stateCreator: zustand.StateCreator) => { 40 | // to support curried version of createStore 41 | return typeof stateCreator === 'function' 42 | ? createStoreUncurried(stateCreator) 43 | : createStoreUncurried; 44 | }) as typeof zustand.createStore; 45 | 46 | export const { useStore } = zustand; 47 | 48 | // reset all stores after each test run 49 | afterEach(() => { 50 | act(() => { 51 | storeResetFns.forEach((resetFn) => { 52 | resetFn(); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /tests/__tests__/createVanillaTemporal.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { temporalStateCreator } from '../../src/temporal'; 3 | import { createStore } from 'zustand'; 4 | import { act } from '@testing-library/react'; 5 | 6 | interface MyState { 7 | count: number; 8 | increment: () => void; 9 | decrement: () => void; 10 | } 11 | 12 | // tests the temporalStateCreator function rather than the temporal middleware 13 | // Not exhaustive, but also likely not needed 14 | describe('temporalStateCreator', () => { 15 | const store = createStore((set) => { 16 | return { 17 | count: 0, 18 | increment: () => 19 | set((state) => ({ 20 | count: state.count + 1, 21 | })), 22 | decrement: () => 23 | set((state) => ({ 24 | count: state.count - 1, 25 | })), 26 | }; 27 | }); 28 | 29 | it('should have the objects defined', () => { 30 | const temporalStore = createStore( 31 | temporalStateCreator(store.setState, store.getState), 32 | ); 33 | const { undo, redo, clear, pastStates, futureStates } = 34 | temporalStore.getState(); 35 | 36 | expect(undo).toBeDefined(); 37 | expect(redo).toBeDefined(); 38 | expect(clear).toBeDefined(); 39 | expect(pastStates).toBeDefined(); 40 | expect(futureStates).toBeDefined(); 41 | 42 | expect(store.getState().count).toBe(0); 43 | act(store.getState().increment); 44 | expect(store.getState().count).toBe(1); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/__tests__/options.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { temporal } from '../../src/index'; 3 | import { createStore, type StoreApi } from 'zustand'; 4 | import { act } from '@testing-library/react'; 5 | import { shallow } from 'zustand/shallow'; 6 | import type { 7 | _TemporalState, 8 | ZundoOptions, 9 | TemporalState, 10 | Write, 11 | } from '../../src/types'; 12 | import throttle from 'lodash.throttle'; 13 | import { persist } from 'zustand/middleware'; 14 | import diff from 'microdiff'; 15 | 16 | const isEmpty = (obj: object) => { 17 | for (const _ in obj) { 18 | return false; 19 | } 20 | return true; 21 | }; 22 | 23 | interface MyState { 24 | count: number; 25 | count2: number; 26 | myString: string; 27 | string2: string; 28 | boolean1: boolean; 29 | boolean2: boolean; 30 | increment: () => void; 31 | incrementCountOnly: () => void; 32 | incrementCount2Only: () => void; 33 | decrement: () => void; 34 | doNothing: () => void; 35 | } 36 | 37 | const createVanillaStore = ( 38 | options?: ZundoOptions>, 39 | ) => { 40 | return createStore()( 41 | temporal((set) => { 42 | return { 43 | count: 0, 44 | count2: 0, 45 | myString: 'hello', 46 | string2: 'world', 47 | boolean1: true, 48 | boolean2: false, 49 | increment: () => 50 | set((state) => ({ 51 | count: state.count + 1, 52 | count2: state.count2 + 1, 53 | })), 54 | decrement: () => 55 | set((state) => ({ 56 | count: state.count - 1, 57 | count2: state.count2 - 1, 58 | })), 59 | incrementCountOnly: () => set((state) => ({ count: state.count + 1 })), 60 | incrementCount2Only: () => 61 | set((state) => ({ count2: state.count2 + 1 })), 62 | doNothing: () => set((state) => ({ ...state })), 63 | }; 64 | }, options), 65 | ); 66 | }; 67 | 68 | describe('Middleware options', () => { 69 | let store: Write< 70 | StoreApi, 71 | { 72 | temporal: StoreApi< 73 | TemporalState<{ 74 | count: number; 75 | }> 76 | >; 77 | } 78 | >; 79 | // Recreate store for each test 80 | beforeEach(() => { 81 | store = createVanillaStore(); 82 | }); 83 | 84 | describe('partialize', () => { 85 | it('should not partialize by default', () => { 86 | const { pastStates, futureStates } = store.temporal.getState(); 87 | expect(pastStates.length).toBe(0); 88 | expect(futureStates.length).toBe(0); 89 | act(() => { 90 | store.getState().increment(); 91 | store.getState().increment(); 92 | }); 93 | expect(store.temporal.getState().pastStates.length).toBe(2); 94 | expect(store.temporal.getState().pastStates[0]).toEqual({ 95 | count: 0, 96 | count2: 0, 97 | increment: expect.any(Function), 98 | decrement: expect.any(Function), 99 | doNothing: expect.any(Function), 100 | incrementCountOnly: expect.any(Function), 101 | incrementCount2Only: expect.any(Function), 102 | myString: 'hello', 103 | string2: 'world', 104 | boolean1: true, 105 | boolean2: false, 106 | }); 107 | expect(store.temporal.getState().pastStates[1]).toEqual({ 108 | count: 1, 109 | count2: 1, 110 | increment: expect.any(Function), 111 | decrement: expect.any(Function), 112 | doNothing: expect.any(Function), 113 | incrementCountOnly: expect.any(Function), 114 | incrementCount2Only: expect.any(Function), 115 | myString: 'hello', 116 | string2: 'world', 117 | boolean1: true, 118 | boolean2: false, 119 | }); 120 | expect(store.getState()).toMatchObject({ count: 2, count2: 2 }); 121 | }); 122 | 123 | it('should partialize the past states', () => { 124 | const storeWithPartialize = createVanillaStore({ 125 | partialize: (state) => ({ 126 | count: state.count, 127 | }), 128 | }); 129 | expect(storeWithPartialize.temporal.getState().pastStates.length).toBe(0); 130 | expect(storeWithPartialize.temporal.getState().futureStates.length).toBe( 131 | 0, 132 | ); 133 | act(() => { 134 | storeWithPartialize.getState().increment(); 135 | storeWithPartialize.getState().increment(); 136 | }); 137 | expect(storeWithPartialize.temporal.getState().pastStates.length).toBe(2); 138 | expect(storeWithPartialize.temporal.getState().pastStates[0]).toEqual({ 139 | count: 0, 140 | }); 141 | expect(storeWithPartialize.temporal.getState().pastStates[1]).toEqual({ 142 | count: 1, 143 | }); 144 | expect(storeWithPartialize.getState()).toMatchObject({ 145 | count: 2, 146 | count2: 2, 147 | }); 148 | }); 149 | 150 | it('should partialize the future states', () => { 151 | const storeWithPartialize = createVanillaStore({ 152 | partialize: (state) => ({ 153 | count: state.count, 154 | }), 155 | }); 156 | const { undo, redo } = storeWithPartialize.temporal.getState(); 157 | expect(storeWithPartialize.temporal.getState().pastStates.length).toBe(0); 158 | expect(storeWithPartialize.temporal.getState().futureStates.length).toBe( 159 | 0, 160 | ); 161 | 162 | act(() => { 163 | storeWithPartialize.getState().increment(); 164 | storeWithPartialize.getState().increment(); 165 | undo(); 166 | }); 167 | expect(storeWithPartialize.temporal.getState().futureStates.length).toBe( 168 | 1, 169 | ); 170 | expect(storeWithPartialize.temporal.getState().futureStates[0]).toEqual({ 171 | count: 2, 172 | }); 173 | expect(storeWithPartialize.getState()).toEqual({ 174 | count: 1, 175 | count2: 2, 176 | increment: expect.any(Function), 177 | decrement: expect.any(Function), 178 | doNothing: expect.any(Function), 179 | incrementCountOnly: expect.any(Function), 180 | incrementCount2Only: expect.any(Function), 181 | boolean1: true, 182 | boolean2: false, 183 | myString: 'hello', 184 | string2: 'world', 185 | }); 186 | act(() => { 187 | undo(); 188 | }); 189 | expect(storeWithPartialize.temporal.getState().futureStates.length).toBe( 190 | 2, 191 | ); 192 | expect(storeWithPartialize.temporal.getState().futureStates[1]).toEqual({ 193 | count: 1, 194 | }); 195 | expect(storeWithPartialize.getState()).toEqual({ 196 | count: 0, 197 | count2: 2, 198 | increment: expect.any(Function), 199 | decrement: expect.any(Function), 200 | doNothing: expect.any(Function), 201 | incrementCountOnly: expect.any(Function), 202 | incrementCount2Only: expect.any(Function), 203 | boolean1: true, 204 | boolean2: false, 205 | myString: 'hello', 206 | string2: 'world', 207 | }); 208 | 209 | act(() => { 210 | redo(); 211 | }); 212 | expect(storeWithPartialize.temporal.getState().futureStates.length).toBe( 213 | 1, 214 | ); 215 | expect(storeWithPartialize.temporal.getState().pastStates.length).toBe(1); 216 | expect(storeWithPartialize.temporal.getState().futureStates[0]).toEqual({ 217 | count: 2, 218 | }); 219 | expect(storeWithPartialize.getState()).toEqual({ 220 | count: 1, 221 | count2: 2, 222 | increment: expect.any(Function), 223 | decrement: expect.any(Function), 224 | doNothing: expect.any(Function), 225 | incrementCountOnly: expect.any(Function), 226 | incrementCount2Only: expect.any(Function), 227 | boolean1: true, 228 | boolean2: false, 229 | myString: 'hello', 230 | string2: 'world', 231 | }); 232 | }); 233 | }); 234 | 235 | describe('limit', () => { 236 | it('should not limit the number of past states when not set', () => { 237 | const { increment } = store.getState(); 238 | act(() => { 239 | increment(); 240 | increment(); 241 | increment(); 242 | increment(); 243 | increment(); 244 | }); 245 | expect(store.temporal.getState().pastStates.length).toBe(5); 246 | expect(store.temporal.getState().pastStates[0]).toMatchObject({ 247 | count: 0, 248 | }); 249 | expect(store.temporal.getState().pastStates[2]).toMatchObject({ 250 | count: 2, 251 | }); 252 | }); 253 | 254 | it('should limit the number of past states when set', () => { 255 | const storeWithLimit = createVanillaStore({ limit: 3 }); 256 | const { increment } = storeWithLimit.getState(); 257 | act(() => { 258 | increment(); 259 | increment(); 260 | increment(); 261 | increment(); 262 | increment(); 263 | }); 264 | expect(storeWithLimit.temporal.getState().pastStates.length).toBe(3); 265 | expect(storeWithLimit.temporal.getState().pastStates[0]).toMatchObject({ 266 | count: 2, 267 | }); 268 | expect(storeWithLimit.temporal.getState().pastStates[2]).toMatchObject({ 269 | count: 4, 270 | }); 271 | }); 272 | }); 273 | 274 | describe('equality function', () => { 275 | it('should use the equality function when set', () => { 276 | const storeWithEquality = createVanillaStore({ 277 | equality: (pastState, currentState) => 278 | currentState.count === pastState.count, 279 | }); 280 | const { doNothing, increment } = storeWithEquality.getState(); 281 | act(() => { 282 | doNothing(); 283 | doNothing(); 284 | }); 285 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(0); 286 | act(() => { 287 | increment(); 288 | doNothing(); 289 | }); 290 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(1); 291 | act(() => { 292 | doNothing(); 293 | increment(); 294 | }); 295 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(2); 296 | }); 297 | 298 | it('should use an external equality function', () => { 299 | const storeWithEquality = createVanillaStore({ 300 | equality: shallow, 301 | }); 302 | const { doNothing, increment } = storeWithEquality.getState(); 303 | act(() => { 304 | doNothing(); 305 | doNothing(); 306 | }); 307 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(0); 308 | act(() => { 309 | increment(); 310 | doNothing(); 311 | }); 312 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(1); 313 | act(() => { 314 | doNothing(); 315 | increment(); 316 | }); 317 | expect(storeWithEquality.temporal.getState().pastStates.length).toBe(2); 318 | }); 319 | 320 | it('should not prevent history if there is no equality function', () => { 321 | const { doNothing, increment } = store.getState(); 322 | act(() => { 323 | doNothing(); 324 | doNothing(); 325 | }); 326 | expect(store.temporal.getState().pastStates.length).toBe(2); 327 | act(() => { 328 | increment(); 329 | doNothing(); 330 | }); 331 | expect(store.temporal.getState().pastStates.length).toBe(4); 332 | act(() => { 333 | doNothing(); 334 | increment(); 335 | }); 336 | expect(store.temporal.getState().pastStates.length).toBe(6); 337 | }); 338 | }); 339 | 340 | describe('diff function', () => { 341 | it('should use the diff function when set', () => { 342 | const storeWithDiff = createVanillaStore({ 343 | diff: (pastState, currentState) => { 344 | const myDiff = diff(currentState, pastState); 345 | const newStateFromDiff = myDiff.reduce( 346 | (acc, difference) => { 347 | type State = typeof acc; 348 | type Key = keyof State; 349 | if (difference.type === 'CHANGE') { 350 | const pathAsString = difference.path.join('.') as Key; 351 | const value = difference.value; 352 | acc[pathAsString] = value; 353 | } 354 | return acc; 355 | }, 356 | {} as Partial, 357 | ); 358 | return isEmpty(newStateFromDiff) ? null : newStateFromDiff; 359 | }, 360 | }); 361 | const { doNothing, increment, incrementCount2Only } = 362 | storeWithDiff.getState(); 363 | const { undo, redo } = storeWithDiff.temporal.getState(); 364 | act(() => { 365 | doNothing(); 366 | doNothing(); 367 | }); 368 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(0); 369 | act(() => { 370 | increment(); 371 | increment(); 372 | doNothing(); 373 | }); 374 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(2); 375 | expect(storeWithDiff.temporal.getState().pastStates[0]).toEqual({ 376 | count: 0, 377 | count2: 0, 378 | }); 379 | expect(storeWithDiff.temporal.getState().pastStates[1]).toEqual({ 380 | count: 1, 381 | count2: 1, 382 | }); 383 | expect(storeWithDiff.getState()).toMatchObject({ 384 | count: 2, 385 | count2: 2, 386 | }); 387 | act(() => { 388 | doNothing(); 389 | incrementCount2Only(); 390 | }); 391 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(3); 392 | expect(storeWithDiff.temporal.getState().pastStates[2]).toEqual({ 393 | count2: 2, 394 | }); 395 | expect(storeWithDiff.getState()).toMatchObject({ 396 | count: 2, 397 | count2: 3, 398 | }); 399 | act(() => { 400 | doNothing(); 401 | incrementCount2Only(); 402 | }); 403 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(4); 404 | expect(storeWithDiff.temporal.getState().pastStates[3]).toEqual({ 405 | count2: 3, 406 | }); 407 | expect(storeWithDiff.getState()).toMatchObject({ 408 | count: 2, 409 | count2: 4, 410 | }); 411 | act(() => { 412 | undo(2); 413 | }); 414 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(2); 415 | expect(storeWithDiff.temporal.getState().pastStates[0]).toEqual({ 416 | count: 0, 417 | count2: 0, 418 | }); 419 | expect(storeWithDiff.temporal.getState().futureStates.length).toBe(2); 420 | expect(storeWithDiff.temporal.getState().futureStates[0]).toEqual({ 421 | count2: 4, 422 | }); 423 | expect(storeWithDiff.temporal.getState().futureStates[1]).toEqual({ 424 | count2: 3, 425 | }); 426 | expect(storeWithDiff.getState()).toMatchObject({ 427 | count: 2, 428 | count2: 2, 429 | }); 430 | act(() => { 431 | undo(); 432 | }); 433 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(1); 434 | expect(storeWithDiff.temporal.getState().pastStates[0]).toEqual({ 435 | count: 0, 436 | count2: 0, 437 | }); 438 | expect(storeWithDiff.temporal.getState().futureStates.length).toBe(3); 439 | expect(storeWithDiff.temporal.getState().futureStates[0]).toEqual({ 440 | count2: 4, 441 | }); 442 | expect(storeWithDiff.temporal.getState().futureStates[1]).toEqual({ 443 | count2: 3, 444 | }); 445 | expect(storeWithDiff.temporal.getState().futureStates[2]).toEqual({ 446 | count: 2, 447 | count2: 2, 448 | }); 449 | expect(storeWithDiff.getState()).toMatchObject({ 450 | count: 1, 451 | count2: 1, 452 | }); 453 | act(() => { 454 | redo(); 455 | }); 456 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(2); 457 | expect(storeWithDiff.temporal.getState().pastStates[0]).toEqual({ 458 | count: 0, 459 | count2: 0, 460 | }); 461 | expect(storeWithDiff.temporal.getState().futureStates.length).toBe(2); 462 | expect(storeWithDiff.temporal.getState().futureStates[0]).toEqual({ 463 | count2: 4, 464 | }); 465 | expect(storeWithDiff.temporal.getState().futureStates[1]).toEqual({ 466 | count2: 3, 467 | }); 468 | expect(storeWithDiff.getState()).toMatchObject({ 469 | count: 2, 470 | count2: 2, 471 | }); 472 | act(() => { 473 | redo(2); 474 | }); 475 | expect(storeWithDiff.temporal.getState().pastStates.length).toBe(4); 476 | expect(storeWithDiff.temporal.getState().pastStates[0]).toEqual({ 477 | count: 0, 478 | count2: 0, 479 | }); 480 | expect(storeWithDiff.temporal.getState().futureStates.length).toBe(0); 481 | expect(storeWithDiff.getState()).toMatchObject({ 482 | count: 2, 483 | count2: 4, 484 | }); 485 | }); 486 | }); 487 | 488 | describe('onSave', () => { 489 | it('should call the onSave function when set through options', () => { 490 | global.console.info = vi.fn(); 491 | const storeWithOnSave = createVanillaStore({ 492 | onSave: (pastStates) => { 493 | console.info(pastStates); 494 | }, 495 | }); 496 | const { doNothing, increment } = storeWithOnSave.getState(); 497 | act(() => { 498 | increment(); 499 | doNothing(); 500 | }); 501 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(2); 502 | expect(console.info).toHaveBeenCalledTimes(2); 503 | }); 504 | 505 | it('should call the onSave function when set through the temporal store function', () => { 506 | global.console.warn = vi.fn(); 507 | const { doNothing, increment } = store.getState(); 508 | const { setOnSave } = store.temporal.getState(); 509 | act(() => { 510 | increment(); 511 | doNothing(); 512 | }); 513 | expect(store.temporal.getState().pastStates.length).toBe(2); 514 | expect(console.warn).toHaveBeenCalledTimes(0); 515 | act(() => { 516 | setOnSave((pastStates, currentState) => { 517 | console.warn(pastStates, currentState); 518 | }); 519 | }); 520 | act(() => { 521 | increment(); 522 | doNothing(); 523 | }); 524 | expect(store.temporal.getState().pastStates.length).toBe(4); 525 | expect(console.warn).toHaveBeenCalledTimes(2); 526 | }); 527 | 528 | it('should call a new onSave function after being set', () => { 529 | global.console.info = vi.fn(); 530 | global.console.warn = vi.fn(); 531 | global.console.error = vi.fn(); 532 | const storeWithOnSave = createVanillaStore({ 533 | onSave: (pastStates) => { 534 | console.info(pastStates); 535 | }, 536 | }); 537 | const { doNothing, increment } = storeWithOnSave.getState(); 538 | const { setOnSave } = storeWithOnSave.temporal.getState(); 539 | act(() => { 540 | increment(); 541 | doNothing(); 542 | }); 543 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(2); 544 | expect(console.info).toHaveBeenCalledTimes(2); 545 | expect(console.warn).toHaveBeenCalledTimes(0); 546 | expect(console.error).toHaveBeenCalledTimes(0); 547 | act(() => { 548 | setOnSave((pastStates, currentState) => { 549 | console.warn(pastStates, currentState); 550 | }); 551 | }); 552 | act(() => { 553 | increment(); 554 | doNothing(); 555 | }); 556 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(4); 557 | expect(console.info).toHaveBeenCalledTimes(2); 558 | expect(console.warn).toHaveBeenCalledTimes(2); 559 | expect(console.error).toHaveBeenCalledTimes(0); 560 | act(() => { 561 | setOnSave((pastStates, currentState) => { 562 | console.error(pastStates, currentState); 563 | }); 564 | }); 565 | act(() => { 566 | increment(); 567 | doNothing(); 568 | }); 569 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(6); 570 | expect(console.info).toHaveBeenCalledTimes(2); 571 | expect(console.warn).toHaveBeenCalledTimes(2); 572 | expect(console.error).toHaveBeenCalledTimes(2); 573 | }); 574 | }); 575 | 576 | describe('handleSet', () => { 577 | it('should update the temporal store as expected if no handleSet options is passed', () => { 578 | const { doNothing, increment } = store.getState(); 579 | act(() => { 580 | increment(); 581 | doNothing(); 582 | }); 583 | expect(store.temporal.getState().pastStates.length).toBe(2); 584 | }); 585 | 586 | it('should call function if set', () => { 587 | global.console.info = vi.fn(); 588 | const storeWithHandleSet = createVanillaStore({ 589 | handleSet: (handleSet) => { 590 | return (state) => { 591 | console.info('handleSet called'); 592 | handleSet(state); 593 | }; 594 | }, 595 | }); 596 | const { doNothing, increment } = storeWithHandleSet.getState(); 597 | act(() => { 598 | increment(); 599 | doNothing(); 600 | }); 601 | expect( 602 | storeWithHandleSet.temporal.getState().pastStates[0], 603 | ).toMatchObject({ 604 | count: 0, 605 | }); 606 | expect( 607 | storeWithHandleSet.temporal.getState().pastStates[1], 608 | ).toMatchObject({ 609 | count: 1, 610 | }); 611 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(2); 612 | expect(console.info).toHaveBeenCalledTimes(2); 613 | act(() => { 614 | storeWithHandleSet.temporal.getState().undo(2); 615 | }); 616 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(0); 617 | expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe( 618 | 2, 619 | ); 620 | expect(console.info).toHaveBeenCalledTimes(2); 621 | }); 622 | 623 | it('should call function if set (wrapTemporal)', () => { 624 | global.console.info = vi.fn(); 625 | const storeWithHandleSet = createVanillaStore({ 626 | wrapTemporal: (config) => { 627 | return (_set, get, store) => { 628 | const set: typeof _set = (...args) => { 629 | console.info('handleSet called'); 630 | _set(...args as Parameters); 631 | }; 632 | return config(set, get, store); 633 | }; 634 | }, 635 | }); 636 | const { doNothing, increment } = storeWithHandleSet.getState(); 637 | act(() => { 638 | increment(); 639 | doNothing(); 640 | }); 641 | expect( 642 | storeWithHandleSet.temporal.getState().pastStates[0], 643 | ).toMatchObject({ 644 | count: 0, 645 | }); 646 | expect( 647 | storeWithHandleSet.temporal.getState().pastStates[1], 648 | ).toMatchObject({ 649 | count: 1, 650 | }); 651 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(2); 652 | expect(console.info).toHaveBeenCalledTimes(2); 653 | act(() => { 654 | storeWithHandleSet.temporal.getState().undo(2); 655 | }); 656 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(0); 657 | expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe( 658 | 2, 659 | ); 660 | // Note: in the above test, the handleSet function is called twice, but in this test it is called 3 times because it is also called when undo() and redo() are called. 661 | expect(console.info).toHaveBeenCalledTimes(3); 662 | }); 663 | 664 | it('should correctly use throttling', () => { 665 | global.console.error = vi.fn(); 666 | vi.useFakeTimers(); 667 | const storeWithHandleSet = createVanillaStore({ 668 | handleSet: (handleSet) => { 669 | return throttle((state) => { 670 | console.error('handleSet called'); 671 | handleSet(state); 672 | }, 1000); 673 | }, 674 | }); 675 | const { doNothing, increment } = storeWithHandleSet.getState(); 676 | act(() => { 677 | increment(); 678 | increment(); 679 | increment(); 680 | increment(); 681 | }); 682 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(1); 683 | expect(console.error).toHaveBeenCalledTimes(1); 684 | vi.advanceTimersByTime(1001); 685 | // By default, lodash.throttle includes trailing event 686 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(2); 687 | expect(console.error).toHaveBeenCalledTimes(2); 688 | act(() => { 689 | doNothing(); 690 | doNothing(); 691 | doNothing(); 692 | doNothing(); 693 | }); 694 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(3); 695 | expect(console.error).toHaveBeenCalledTimes(3); 696 | vi.advanceTimersByTime(1001); 697 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(4); 698 | expect(console.error).toHaveBeenCalledTimes(4); 699 | act(() => { 700 | // Does not call handle set (and is not throttled) 701 | storeWithHandleSet.temporal.getState().undo(4); 702 | storeWithHandleSet.temporal.getState().redo(1); 703 | }); 704 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(1); 705 | expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe( 706 | 3, 707 | ); 708 | expect(console.error).toHaveBeenCalledTimes(4); 709 | vi.useRealTimers(); 710 | }); 711 | 712 | it('should correctly use throttling (wrapTemporal)', () => { 713 | global.console.error = vi.fn(); 714 | vi.useFakeTimers(); 715 | const storeWithHandleSet = createVanillaStore({ 716 | wrapTemporal: (config) => { 717 | return (_set, get, store) => { 718 | const set: typeof _set = throttle( 719 | (...args) => { 720 | console.error('handleSet called'); 721 | _set(...args as Parameters); 722 | }, 723 | 1000, 724 | ); 725 | return config(set, get, store); 726 | }; 727 | }, 728 | }); 729 | const { doNothing, increment } = storeWithHandleSet.getState(); 730 | act(() => { 731 | increment(); 732 | }); 733 | vi.runAllTimers(); 734 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(1); 735 | expect(console.error).toHaveBeenCalledTimes(1); 736 | act(() => { 737 | doNothing(); 738 | }); 739 | vi.runAllTimers(); 740 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(2); 741 | expect(console.error).toHaveBeenCalledTimes(2); 742 | act(() => { 743 | storeWithHandleSet.temporal.getState().undo(2); 744 | }); 745 | vi.runAllTimers(); 746 | expect(storeWithHandleSet.temporal.getState().pastStates.length).toBe(0); 747 | expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe( 748 | 2, 749 | ); 750 | expect(console.error).toHaveBeenCalledTimes(3); 751 | }); 752 | 753 | it('should not call throttle function if partialized state is unchanged according to equality fn', () => { 754 | global.console.error = vi.fn(); 755 | vi.useFakeTimers(); 756 | const throttleIntervalInMs = 1000; 757 | const storeWithHandleSetAndPartializeAndEquality = createVanillaStore({ 758 | handleSet: (handleSet) => { 759 | return throttle( 760 | (state) => { 761 | // used for determining how many times `handleSet` is called 762 | console.error('handleSet called'); 763 | handleSet(state); 764 | }, 765 | throttleIntervalInMs, 766 | // Call throttle only on leading edge of timeout 767 | { leading: true, trailing: false }, 768 | ); 769 | }, 770 | partialize: (state) => ({ 771 | count: state.count, 772 | }), 773 | equality: (pastState, currentState) => 774 | diff(pastState, currentState).length === 0, 775 | }); 776 | 777 | const { incrementCountOnly, incrementCount2Only } = 778 | storeWithHandleSetAndPartializeAndEquality.getState(); 779 | // Increment value not included in partialized state 780 | act(() => { 781 | incrementCount2Only(); 782 | }); 783 | // Proxy for determining how many times `handleSet` is called. 784 | // handleSet should not be called if partialized state is unchanged 785 | expect(console.error).toHaveBeenCalledTimes(0); 786 | expect( 787 | storeWithHandleSetAndPartializeAndEquality.temporal.getState() 788 | .pastStates.length, 789 | ).toBe(0); 790 | // Advance timer to be within throttle interval 791 | vi.advanceTimersByTime(throttleIntervalInMs / 2); 792 | act(() => { 793 | incrementCountOnly(); 794 | }); 795 | // Count is in partialized state, so handleSet should have been called 796 | expect(console.error).toHaveBeenCalledTimes(1); 797 | // The first instance of a partialized state changing should add to history 798 | expect( 799 | storeWithHandleSetAndPartializeAndEquality.temporal.getState() 800 | .pastStates.length, 801 | ).toBe(1); 802 | vi.useRealTimers(); 803 | }); 804 | 805 | it('should not call throttle function if partialized state is unchanged according to diff fn', () => { 806 | global.console.error = vi.fn(); 807 | vi.useFakeTimers(); 808 | const throttleIntervalInMs = 1000; 809 | const storeWithHandleSetAndPartializeAndDiff = createVanillaStore({ 810 | handleSet: (handleSet) => { 811 | return throttle( 812 | (state) => { 813 | // used for determining how many times `handleSet` is called 814 | console.error('handleSet called'); 815 | handleSet(state); 816 | }, 817 | throttleIntervalInMs, 818 | // Call throttle only on leading edge of timeout 819 | { leading: true, trailing: false }, 820 | ); 821 | }, 822 | partialize: (state) => ({ 823 | count: state.count, 824 | }), 825 | diff: (pastState, currentState) => { 826 | const myDiff = diff(currentState, pastState); 827 | const newStateFromDiff = myDiff.reduce( 828 | (acc, difference) => { 829 | type State = typeof acc; 830 | type Key = keyof State; 831 | if (difference.type === 'CHANGE') { 832 | const pathAsString = difference.path.join('.') as Key; 833 | const value = difference.value; 834 | acc[pathAsString] = value; 835 | } 836 | return acc; 837 | }, 838 | {} as Partial, 839 | ); 840 | return isEmpty(newStateFromDiff) ? null : newStateFromDiff; 841 | }, 842 | }); 843 | 844 | const { incrementCountOnly, incrementCount2Only } = 845 | storeWithHandleSetAndPartializeAndDiff.getState(); 846 | // Increment value not included in partialized state 847 | act(() => { 848 | incrementCount2Only(); 849 | }); 850 | // Proxy for determining how many times `handleSet` is called. 851 | // handleSet should not be called if partialized state is unchanged 852 | expect(console.error).toHaveBeenCalledTimes(0); 853 | expect( 854 | storeWithHandleSetAndPartializeAndDiff.temporal.getState().pastStates 855 | .length, 856 | ).toBe(0); 857 | // Advance timer to be within throttle interval 858 | vi.advanceTimersByTime(throttleIntervalInMs / 2); 859 | act(() => { 860 | incrementCountOnly(); 861 | }); 862 | // Count is in partialized state, so handleSet should have been called 863 | expect(console.error).toHaveBeenCalledTimes(1); 864 | // The first instance of a partialized state changing should add to history 865 | expect( 866 | storeWithHandleSetAndPartializeAndDiff.temporal.getState().pastStates 867 | .length, 868 | ).toBe(1); 869 | vi.useRealTimers(); 870 | }); 871 | 872 | it('should always call throttle function on any partialized or non-partialized state change if no equality or diff fn is provided', () => { 873 | global.console.error = vi.fn(); 874 | vi.useFakeTimers(); 875 | const throttleIntervalInMs = 1000; 876 | const storeWithHandleSetAndPartializeAndDiff = createVanillaStore({ 877 | handleSet: (handleSet) => { 878 | return throttle( 879 | (state) => { 880 | // used for determining how many times `handleSet` is called 881 | console.error('handleSet called'); 882 | handleSet(state); 883 | }, 884 | throttleIntervalInMs, 885 | // Call throttle only on leading edge of timeout 886 | { leading: true, trailing: false }, 887 | ); 888 | }, 889 | partialize: (state) => ({ 890 | count: state.count, 891 | }), 892 | }); 893 | 894 | const { incrementCountOnly, incrementCount2Only } = 895 | storeWithHandleSetAndPartializeAndDiff.getState(); 896 | // Increment value not included in partialized state 897 | act(() => { 898 | incrementCount2Only(); 899 | }); 900 | // Proxy for determining how many times `handleSet` is called. 901 | // If no diff nor equality fn is provided, handleSet will be called on all zustand state setting calls. 902 | expect(console.error).toHaveBeenCalledTimes(1); 903 | expect( 904 | storeWithHandleSetAndPartializeAndDiff.temporal.getState().pastStates 905 | .length, 906 | ).toBe(1); 907 | // Advance timer to be within throttle interval 908 | vi.advanceTimersByTime(throttleIntervalInMs / 2); 909 | act(() => { 910 | incrementCountOnly(); 911 | }); 912 | // Throttle should be active, so handleSet shouldn't have been called again 913 | expect(console.error).toHaveBeenCalledTimes(1); 914 | // The first instance of a partialized state changing should add to history 915 | expect( 916 | storeWithHandleSetAndPartializeAndDiff.temporal.getState().pastStates 917 | .length, 918 | ).toBe(1); 919 | // Advance timer to be out of throttle interval 920 | vi.advanceTimersByTime(throttleIntervalInMs); 921 | act(() => { 922 | incrementCountOnly(); 923 | }); 924 | expect( 925 | storeWithHandleSetAndPartializeAndDiff.temporal.getState().pastStates 926 | .length, 927 | ).toBe(2); 928 | vi.useRealTimers(); 929 | }); 930 | }); 931 | 932 | describe('wrapTemporal', () => { 933 | describe('should wrap temporal store in given middlewares', () => { 934 | it('persist', () => { 935 | const storeWithTemporalWithPersist = createVanillaStore({ 936 | wrapTemporal: (config) => persist(config, { name: '123' }), 937 | }); 938 | 939 | expect(storeWithTemporalWithPersist.temporal).toHaveProperty('persist'); 940 | }); 941 | 942 | it('temporal', () => { 943 | const storeWithTemporalWithTemporal = createVanillaStore({ 944 | wrapTemporal: (store) => temporal(store), 945 | }); 946 | expect(storeWithTemporalWithTemporal.temporal).toHaveProperty( 947 | 'temporal', 948 | ); 949 | }); 950 | 951 | it('temporal and persist', () => { 952 | const storeWithTemporalWithMiddleware = createVanillaStore({ 953 | wrapTemporal: (store) => temporal(persist(store, { name: '123' })), 954 | }); 955 | expect(storeWithTemporalWithMiddleware.temporal).toHaveProperty( 956 | 'persist', 957 | ); 958 | expect(storeWithTemporalWithMiddleware.temporal).toHaveProperty( 959 | 'temporal', 960 | ); 961 | }); 962 | }); 963 | }); 964 | 965 | describe('secret internals', () => { 966 | it('should have a secret internal state', () => { 967 | const { _handleSet, _onSave } = 968 | store.temporal.getState() as _TemporalState; 969 | expect(_handleSet).toBeInstanceOf(Function); 970 | expect(_onSave).toBe(undefined); 971 | }); 972 | describe('onSave', () => { 973 | it('should call onSave cb without adding a new state when onSave is set by user', () => { 974 | global.console.error = vi.fn(); 975 | const { setOnSave } = store.temporal.getState(); 976 | act(() => { 977 | setOnSave((pastStates, currentState) => { 978 | console.error(pastStates, currentState); 979 | }); 980 | }); 981 | const { _onSave } = 982 | store.temporal.getState() as _TemporalState; 983 | act(() => { 984 | _onSave?.(store.getState(), store.getState()); 985 | }); 986 | expect(_onSave).toBeInstanceOf(Function); 987 | expect(store.temporal.getState().pastStates.length).toBe(0); 988 | expect(console.error).toHaveBeenCalledTimes(1); 989 | }); 990 | it('should call onSave cb without adding a new state when onSave is set at store init options', () => { 991 | global.console.info = vi.fn(); 992 | const storeWithOnSave = createVanillaStore({ 993 | onSave: (pastStates) => { 994 | console.info(pastStates); 995 | }, 996 | }); 997 | const { _onSave } = 998 | storeWithOnSave.temporal.getState() as _TemporalState; 999 | act(() => { 1000 | _onSave?.(storeWithOnSave.getState(), storeWithOnSave.getState()); 1001 | }); 1002 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(0); 1003 | expect(console.info).toHaveBeenCalledTimes(1); 1004 | }); 1005 | it('should call onSave cb without adding a new state and respond to new setOnSave', () => { 1006 | global.console.dir = vi.fn(); 1007 | global.console.trace = vi.fn(); 1008 | const storeWithOnSave = createVanillaStore({ 1009 | onSave: (pastStates) => { 1010 | console.dir(pastStates); 1011 | }, 1012 | }); 1013 | act(() => { 1014 | ( 1015 | storeWithOnSave.temporal.getState() as _TemporalState 1016 | )._onSave?.(storeWithOnSave.getState(), storeWithOnSave.getState()); 1017 | }); 1018 | expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(0); 1019 | expect(console.dir).toHaveBeenCalledTimes(1); 1020 | expect(console.trace).toHaveBeenCalledTimes(0); 1021 | 1022 | const { setOnSave } = storeWithOnSave.temporal.getState(); 1023 | act(() => { 1024 | setOnSave((pastStates, currentState) => { 1025 | console.trace(pastStates, currentState); 1026 | }); 1027 | }); 1028 | act(() => { 1029 | ( 1030 | storeWithOnSave.temporal.getState() as _TemporalState 1031 | )._onSave?.(store.getState(), store.getState()); 1032 | }); 1033 | expect(store.temporal.getState().pastStates.length).toBe(0); 1034 | expect(console.dir).toHaveBeenCalledTimes(1); 1035 | expect(console.trace).toHaveBeenCalledTimes(1); 1036 | }); 1037 | }); 1038 | 1039 | describe('handleUserSet', () => { 1040 | it('should update the temporal store with the pastState when called', () => { 1041 | const { _handleSet } = 1042 | store.temporal.getState() as _TemporalState; 1043 | act(() => { 1044 | _handleSet( 1045 | store.getState(), 1046 | undefined as unknown as Parameters[1], 1047 | store.getState(), 1048 | null, 1049 | ); 1050 | }); 1051 | expect(store.temporal.getState().pastStates.length).toBe(1); 1052 | }); 1053 | 1054 | // TODO: should this check the equality function, limit, and call onSave? These are already tested but indirectly. 1055 | }); 1056 | }); 1057 | 1058 | describe('init pastStates', () => { 1059 | it('should init the pastStates with the initial state', () => { 1060 | const storeWithPastStates = createVanillaStore({ 1061 | pastStates: [{ count: 0 }, { count: 1 }], 1062 | }); 1063 | expect(storeWithPastStates.temporal.getState().pastStates.length).toBe(2); 1064 | }); 1065 | it('should be able to call undo on init pastStates', () => { 1066 | const storeWithPastStates = createVanillaStore({ 1067 | pastStates: [{ count: 999 }, { count: 1000 }], 1068 | }); 1069 | expect(storeWithPastStates.getState().count).toBe(0); 1070 | act(() => { 1071 | storeWithPastStates.temporal.getState().undo(); 1072 | }); 1073 | expect(storeWithPastStates.getState().count).toBe(1000); 1074 | }); 1075 | }); 1076 | 1077 | describe('init futureStates', () => { 1078 | it('should init the futureStates with the initial state', () => { 1079 | const storeWithFutureStates = createVanillaStore({ 1080 | futureStates: [{ count: 0 }, { count: 1 }], 1081 | }); 1082 | expect( 1083 | storeWithFutureStates.temporal.getState().futureStates.length, 1084 | ).toBe(2); 1085 | }); 1086 | it('should be able to call redo on init futureStates', () => { 1087 | const storeWithFutureStates = createVanillaStore({ 1088 | futureStates: [{ count: 1001 }, { count: 1000 }], 1089 | }); 1090 | expect(storeWithFutureStates.getState().count).toBe(0); 1091 | act(() => { 1092 | storeWithFutureStates.temporal.getState().redo(); 1093 | }); 1094 | expect(storeWithFutureStates.getState().count).toBe(1000); 1095 | }); 1096 | }); 1097 | }); 1098 | 1099 | describe('setState', () => { 1100 | it('it should correctly update the state', () => { 1101 | const store = createVanillaStore(); 1102 | const setState = store.setState; 1103 | act(() => { 1104 | setState({ count: 100 }); 1105 | }); 1106 | expect(store.getState().count).toBe(100); 1107 | expect(store.temporal.getState().pastStates.length).toBe(1); 1108 | act(() => { 1109 | store.temporal.getState().undo(); 1110 | }); 1111 | expect(store.getState().count).toBe(0); 1112 | expect(store.temporal.getState().pastStates.length).toBe(0); 1113 | expect(store.temporal.getState().futureStates.length).toBe(1); 1114 | }); 1115 | }); 1116 | -------------------------------------------------------------------------------- /tests/__tests__/react.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | 4 | describe('React Re-renders when state changes', () => { 5 | it('it', () => { 6 | const { queryByText, getByText } = render(); 7 | 8 | expect(queryByText(/bears: 0/i)).toBeTruthy(); 9 | expect(queryByText(/increment/i)).toBeTruthy(); 10 | expect(queryByText(/past states: \[\]/i)).toBeTruthy(); 11 | expect(queryByText(/future states: \[\]/i)).toBeTruthy(); 12 | 13 | const incrementButton = getByText(/increment/i); 14 | fireEvent.click(incrementButton); 15 | fireEvent.click(incrementButton); 16 | 17 | expect(queryByText(/bears: 2/i)).toBeTruthy(); 18 | expect( 19 | queryByText(/past states: \[{"bears":0},{"bears":1}\]/i), 20 | ).toBeTruthy(); 21 | expect(queryByText(/future states: \[\]/i)).toBeTruthy(); 22 | 23 | expect( 24 | queryByText(/undo/i, { 25 | selector: 'button', 26 | }), 27 | ).toBeTruthy(); 28 | 29 | const undoButton = getByText(/undo/i, { 30 | selector: 'button', 31 | }); 32 | 33 | fireEvent.click(undoButton); 34 | fireEvent.click(undoButton); 35 | 36 | expect(queryByText(/bears: 0/i)).toBeTruthy(); 37 | expect(queryByText(/past states: \[\]/i)).toBeTruthy(); 38 | expect( 39 | queryByText(/future states: \[{"bears":2},{"bears":1}\]/i), 40 | ).toBeTruthy(); 41 | }); 42 | }); 43 | 44 | // React Code from examples/web/pages/reactive.tsx 45 | import { type TemporalState, temporal } from '../../src'; 46 | import { type StoreApi, useStore, create } from 'zustand'; 47 | 48 | interface MyState { 49 | bears: number; 50 | increment: () => void; 51 | decrement: () => void; 52 | } 53 | 54 | const useMyStore = create( 55 | temporal((set) => ({ 56 | bears: 0, 57 | increment: () => set((state) => ({ bears: state.bears + 1 })), 58 | decrement: () => set((state) => ({ bears: state.bears - 1 })), 59 | })), 60 | ); 61 | 62 | type ExtractState = S extends { 63 | getState: () => infer T; 64 | } 65 | ? T 66 | : never; 67 | type ReadonlyStoreApi = Pick, 'getState' | 'subscribe'>; 68 | type WithReact> = S & { 69 | getServerState?: () => ExtractState; 70 | }; 71 | 72 | const useTemporalStore = < 73 | S extends WithReact>>, 74 | U, 75 | >( 76 | selector: (state: ExtractState) => U, 77 | ): U => { 78 | const state = useStore(useMyStore.temporal as any, selector); 79 | return state; 80 | }; 81 | 82 | const HistoryBar = () => { 83 | const futureStates = useTemporalStore((state) => state.futureStates); 84 | const pastStates = useTemporalStore((state) => state.pastStates); 85 | return ( 86 |
87 | past states: {JSON.stringify(pastStates)} 88 |
89 | future states: {JSON.stringify(futureStates)} 90 |
91 |
92 | ); 93 | }; 94 | 95 | const UndoBar = () => { 96 | const undo = useTemporalStore((state) => state.undo); 97 | const redo = useTemporalStore((state) => state.redo); 98 | return ( 99 |
100 | 101 | 102 |
103 | ); 104 | }; 105 | 106 | const StateBar = () => { 107 | const store = useMyStore(); 108 | const { bears, increment, decrement } = store; 109 | return ( 110 |
111 | current state: {JSON.stringify(store)} 112 |
113 |
114 | bears: {bears} 115 |
116 | 117 | 118 |
119 | ); 120 | }; 121 | 122 | const Reactive = () => { 123 | return ( 124 |
125 |

126 | {' '} 127 | 128 | 🐻 129 | {' '} 130 | 131 | ♻️ 132 | {' '} 133 | Zundo! 134 |

135 | 136 |
137 | 138 | 139 |
140 | ); 141 | }; 142 | -------------------------------------------------------------------------------- /tests/__tests__/zundo.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 2 | import { temporal } from '../../src/index'; 3 | import { createStore, type StoreApi } from 'zustand'; 4 | import { act } from '@testing-library/react'; 5 | import type { TemporalState, Write } from '../../src/types'; 6 | 7 | interface MyState { 8 | count: number; 9 | count2: number; 10 | increment: () => void; 11 | decrement: () => void; 12 | } 13 | 14 | describe('temporal middleware', () => { 15 | let store: Write< 16 | StoreApi, 17 | { 18 | temporal: StoreApi>; 19 | } 20 | >; 21 | // Recreate store for each test 22 | beforeEach(() => { 23 | store = createStore()( 24 | temporal((set) => { 25 | return { 26 | count: 0, 27 | count2: 0, 28 | increment: () => 29 | set((state) => ({ 30 | count: state.count + 1, 31 | count2: state.count2 + 1, 32 | })), 33 | decrement: () => 34 | set((state) => ({ 35 | count: state.count - 1, 36 | count2: state.count2 - 1, 37 | })), 38 | }; 39 | }), 40 | ); 41 | }); 42 | 43 | it('should have the objects defined', () => { 44 | const { 45 | undo, 46 | redo, 47 | clear, 48 | pastStates, 49 | futureStates, 50 | isTracking, 51 | pause, 52 | resume, 53 | setOnSave, 54 | } = store.temporal.getState(); 55 | expect(undo).toBeDefined(); 56 | expect(redo).toBeDefined(); 57 | expect(clear).toBeDefined(); 58 | expect(pastStates).toBeDefined(); 59 | expect(futureStates).toBeDefined(); 60 | expect(isTracking).toBeDefined(); 61 | expect(pause).toBeDefined(); 62 | expect(resume).toBeDefined(); 63 | expect(setOnSave).toBeDefined(); 64 | 65 | expect(store.getState().count).toBe(0); 66 | act(() => { 67 | store.getState().increment(); 68 | }); 69 | expect(store.getState().count).toBe(1); 70 | }); 71 | 72 | describe('undo', () => { 73 | it('should undo', () => { 74 | const { undo } = store.temporal.getState(); 75 | expect(store.getState().count).toBe(0); 76 | act(() => { 77 | store.getState().increment(); 78 | }); 79 | expect(store.getState().count).toBe(1); 80 | act(() => { 81 | undo(); 82 | }); 83 | expect(store.getState().count).toBe(0); 84 | }); 85 | 86 | it('should undo multiple states (step)', () => { 87 | const { undo, pastStates } = store.temporal.getState(); 88 | expect(pastStates.length).toBe(0); 89 | act(() => { 90 | store.getState().increment(); 91 | store.getState().increment(); 92 | store.getState().increment(); 93 | store.getState().increment(); 94 | store.getState().increment(); 95 | store.getState().increment(); 96 | }); 97 | expect(store.temporal.getState().pastStates.length).toBe(6); 98 | act(() => { 99 | undo(4); 100 | }); 101 | expect(store.temporal.getState().pastStates.length).toBe(2); 102 | expect(store.getState().count).toBe(2); 103 | expect( 104 | store.temporal.getState().futureStates.map((state) => state.count), 105 | ).toEqual([6, 5, 4, 3]); 106 | act(() => { 107 | undo(2); 108 | }); 109 | expect(store.temporal.getState().pastStates.length).toBe(0); 110 | expect(store.getState().count).toBe(0); 111 | }); 112 | }); 113 | 114 | describe('redo', () => { 115 | it('should redo', () => { 116 | const { undo, redo } = store.temporal.getState(); 117 | expect(store.getState().count).toBe(0); 118 | act(() => { 119 | store.getState().increment(); 120 | }); 121 | expect(store.getState().count).toBe(1); 122 | act(() => { 123 | undo(); 124 | }); 125 | expect(store.getState().count).toBe(0); 126 | act(() => { 127 | redo(); 128 | }); 129 | expect(store.getState().count).toBe(1); 130 | }); 131 | 132 | it('should redo multiple states (step)', () => { 133 | const { undo, redo, pastStates, futureStates } = 134 | store.temporal.getState(); 135 | expect(pastStates.length).toBe(0); 136 | act(() => { 137 | store.getState().increment(); 138 | store.getState().increment(); 139 | store.getState().increment(); 140 | store.getState().increment(); 141 | store.getState().increment(); 142 | store.getState().increment(); 143 | }); 144 | expect(store.temporal.getState().pastStates.length).toBe(6); 145 | act(() => { 146 | undo(4); 147 | }); 148 | expect(store.temporal.getState().pastStates.length).toBe(2); 149 | expect(store.getState().count).toBe(2); 150 | expect(store.temporal.getState().futureStates.length).toBe(4); 151 | act(() => { 152 | redo(4); 153 | }); 154 | expect(store.temporal.getState().pastStates.length).toBe(6); 155 | expect( 156 | store.temporal.getState().pastStates.map((state) => state.count), 157 | ).toEqual([0, 1, 2, 3, 4, 5]); 158 | expect(store.getState().count).toBe(6); 159 | }); 160 | }); 161 | 162 | it('should clear', () => { 163 | const { undo, redo, clear, pastStates, futureStates } = 164 | store.temporal.getState(); 165 | expect(pastStates.length).toBe(0); 166 | act(() => { 167 | store.getState().increment(); 168 | }); 169 | expect(store.temporal.getState().pastStates.length).toBe(1); 170 | act(() => { 171 | store.getState().increment(); 172 | store.getState().decrement(); 173 | }); 174 | expect(store.temporal.getState().pastStates.length).toBe(3); 175 | act(() => { 176 | undo(2); 177 | }); 178 | expect(store.temporal.getState().pastStates.length).toBe(1); 179 | expect(store.temporal.getState().futureStates.length).toBe(2); 180 | act(() => { 181 | redo(); 182 | }); 183 | expect(store.temporal.getState().pastStates.length).toBe(2); 184 | expect(store.temporal.getState().futureStates.length).toBe(1); 185 | act(() => { 186 | clear(); 187 | }); 188 | expect(store.temporal.getState().pastStates.length).toBe(0); 189 | expect(store.temporal.getState().futureStates.length).toBe(0); 190 | }); 191 | 192 | it('should update pastStates', () => { 193 | const { undo, redo, clear, pastStates } = store.temporal.getState(); 194 | expect(store.temporal.getState().pastStates.length).toBe(0); 195 | act(() => { 196 | store.getState().increment(); 197 | }); 198 | expect(store.temporal.getState().pastStates.length).toBe(1); 199 | act(() => { 200 | store.getState().decrement(); 201 | }); 202 | expect(store.temporal.getState().pastStates.length).toBe(2); 203 | act(() => { 204 | undo(); 205 | }); 206 | expect(store.temporal.getState().pastStates.length).toBe(1); 207 | act(() => { 208 | undo(); 209 | }); 210 | expect(store.temporal.getState().pastStates.length).toBe(0); 211 | act(() => { 212 | redo(); 213 | }); 214 | expect(store.temporal.getState().pastStates.length).toBe(1); 215 | act(() => { 216 | clear(); 217 | }); 218 | expect(store.temporal.getState().pastStates.length).toBe(0); 219 | }); 220 | 221 | it('should update futureStates', () => { 222 | const { undo, redo, clear, futureStates } = store.temporal.getState(); 223 | expect(futureStates.length).toBe(0); 224 | act(() => { 225 | store.getState().increment(); 226 | }); 227 | expect(store.temporal.getState().futureStates.length).toBe(0); 228 | act(() => { 229 | store.getState().increment(); 230 | store.getState().decrement(); 231 | }); 232 | expect(store.temporal.getState().futureStates.length).toBe(0); 233 | act(() => { 234 | undo(2); 235 | }); 236 | expect(store.temporal.getState().futureStates.length).toBe(2); 237 | act(() => { 238 | redo(); 239 | }); 240 | expect(store.temporal.getState().futureStates.length).toBe(1); 241 | act(() => { 242 | clear(); 243 | }); 244 | expect(store.temporal.getState().futureStates.length).toBe(0); 245 | }); 246 | 247 | it('properly tracks state values after clearing', () => { 248 | const { undo, redo, clear, pastStates, futureStates } = 249 | store.temporal.getState(); 250 | expect(pastStates.length).toBe(0); 251 | act(() => { 252 | store.getState().increment(); 253 | store.getState().increment(); 254 | store.getState().increment(); 255 | }); 256 | expect(store.temporal.getState().pastStates.length).toBe(3); 257 | act(() => { 258 | clear(); 259 | }); 260 | expect(store.temporal.getState().pastStates.length).toBe(0); 261 | expect(store.temporal.getState().futureStates.length).toBe(0); 262 | expect(store.temporal.getState().pastStates).toEqual([]); 263 | expect(store.temporal.getState().futureStates).toEqual([]); 264 | expect(store.getState().count).toBe(3); 265 | act(() => { 266 | store.getState().increment(); 267 | }); 268 | expect(store.temporal.getState().pastStates.length).toBe(1); 269 | expect(store.getState().count).toBe(4); 270 | act(() => { 271 | store.getState().increment(); 272 | store.getState().increment(); 273 | store.getState().increment(); 274 | }); 275 | expect(store.temporal.getState().pastStates.length).toBe(4); 276 | expect(store.getState().count).toBe(7); 277 | act(() => { 278 | undo(3); 279 | }); 280 | expect(store.temporal.getState().pastStates.length).toBe(1); 281 | expect(store.getState().count).toBe(4); 282 | expect(store.temporal.getState().futureStates.length).toBe(3); 283 | act(() => { 284 | clear(); 285 | }); 286 | expect(store.temporal.getState().pastStates.length).toBe(0); 287 | expect(store.temporal.getState().futureStates.length).toBe(0); 288 | expect(store.temporal.getState().pastStates).toEqual([]); 289 | expect(store.temporal.getState().futureStates).toEqual([]); 290 | expect(store.getState().count).toBe(4); 291 | }); 292 | 293 | it('should clear future states when set is called', () => { 294 | const { undo, redo, clear, pastStates, futureStates } = 295 | store.temporal.getState(); 296 | expect(pastStates.length).toBe(0); 297 | expect(futureStates.length).toBe(0); 298 | act(() => { 299 | store.getState().increment(); 300 | store.getState().increment(); 301 | store.getState().increment(); 302 | }); 303 | expect(store.temporal.getState().pastStates.length).toBe(3); 304 | expect(store.temporal.getState().futureStates.length).toBe(0); 305 | act(() => { 306 | undo(2); 307 | }); 308 | expect(store.temporal.getState().pastStates.length).toBe(1); 309 | expect(store.temporal.getState().futureStates.length).toBe(2); 310 | act(() => { 311 | store.getState().increment(); 312 | store.getState().increment(); 313 | store.getState().increment(); 314 | }); 315 | expect(store.temporal.getState().pastStates.length).toBe(4); 316 | expect(store.temporal.getState().futureStates.length).toBe(0); 317 | }); 318 | 319 | describe('temporal tracking status', () => { 320 | it('should initialize state to tracking', () => { 321 | const { isTracking } = store.temporal.getState(); 322 | expect(isTracking).toBe(true); 323 | }); 324 | 325 | it('should switch to paused', () => { 326 | const { pause } = store.temporal.getState(); 327 | act(() => { 328 | pause(); 329 | }); 330 | expect(store.temporal.getState().isTracking).toBe(false); 331 | }); 332 | 333 | it('should switch to tracking', () => { 334 | const { resume, pause } = store.temporal.getState(); 335 | act(() => { 336 | pause(); 337 | resume(); 338 | }); 339 | expect(store.temporal.getState().isTracking).toBe(true); 340 | }); 341 | 342 | it('does not track state when paused', () => { 343 | const { pause, resume } = store.temporal.getState(); 344 | act(() => { 345 | pause(); 346 | store.getState().increment(); 347 | }); 348 | expect(store.temporal.getState().pastStates.length).toBe(0); 349 | expect(store.getState()).toMatchObject({ count: 1, count2: 1 }); 350 | act(() => { 351 | resume(); 352 | store.getState().increment(); 353 | }); 354 | expect(store.temporal.getState().pastStates.length).toBe(1); 355 | expect(store.temporal.getState().pastStates[0]).toMatchObject({ 356 | count: 1, 357 | count2: 1, 358 | }); 359 | expect(store.getState()).toMatchObject({ count: 2, count2: 2 }); 360 | }); 361 | }); 362 | 363 | // Note: setOnSave and __internals are tested in options.test.ts since they are closely related 364 | }); 365 | -------------------------------------------------------------------------------- /tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tests", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vitest --watch", 8 | "test": "vitest --coverage", 9 | "test:ci": "vitest run", 10 | "test:ui": "vitest --ui --coverage" 11 | }, 12 | "devDependencies": { 13 | "@testing-library/jest-dom": "6.6.3", 14 | "@testing-library/react": "16.2.0", 15 | "@types/lodash.throttle": "4.1.9", 16 | "@types/react-dom": "19.0.3", 17 | "@vitest/coverage-v8": "3.0.2", 18 | "@vitest/ui": "3.0.2", 19 | "happy-dom": "16.6.0", 20 | "lodash.throttle": "4.1.1", 21 | "microdiff": "1.5.0", 22 | "react": "19.0.0", 23 | "react-dom": "19.0.0", 24 | "vitest": "3.0.2", 25 | "vitest-localstorage-mock": "0.1.2", 26 | "zustand": "5.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["**/*.ts", "**/*.tsx", "**/*.mjs"], 4 | "exclude": ["node_modules"], 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "rootDir": "../", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "allowJs": true, 10 | "noEmit": true, 11 | "incremental": true, 12 | "module": "ESNext", 13 | "resolveJsonModule": true, 14 | "esModuleInterop": true, 15 | "moduleResolution": "Bundler" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /tests/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | name: 'zundo', 6 | globals: true, 7 | environment: 'happy-dom', 8 | dir: '.', 9 | setupFiles: ['./vitest.setup.ts', 'vitest-localstorage-mock'], 10 | coverage: { 11 | include: ['**/src/**/*.{ts,tsx}'], 12 | allowExternal: true, 13 | reportOnFailure: true, 14 | reporter: ['text', 'json-summary', 'json', 'html'], 15 | }, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /tests/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | // https://github.com/pmndrs/zustand/blob/main/docs/guides/testing.md 2 | import '@testing-library/jest-dom'; 3 | import { vi } from 'vitest'; 4 | 5 | vi.mock('zustand'); // to make it work like Jest (auto-mocking) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "exactOptionalPropertyTypes": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "isolatedModules": true, 12 | "module": "esnext", 13 | "moduleDetection": "force", 14 | "moduleResolution": "bundler", 15 | "noEmit": true, 16 | "noImplicitOverride": true, 17 | "noUncheckedIndexedAccess": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "outDir": "dist", 21 | "rootDir": ".", 22 | "skipLibCheck": true, 23 | "strict": true, 24 | "target": "esnext", 25 | "verbatimModuleSyntax": true 26 | }, 27 | "display": "zundo", 28 | "exclude": [ 29 | "node_modules", 30 | "dist" 31 | ], 32 | "include": [ 33 | "src/**/*", 34 | "tests/**/*" 35 | ] 36 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | sourcemap: true, 6 | clean: true, 7 | dts: true, 8 | format: ['cjs', 'esm'], 9 | }); 10 | -------------------------------------------------------------------------------- /zundo-mascot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charkour/zundo/519479c7d6c59ccd166cf090539e606642aa654c/zundo-mascot.png --------------------------------------------------------------------------------