Skip to content

Commit

Permalink
Merge pull request #18 from charkour/history-depth
Browse files Browse the repository at this point in the history
History depth limit
  • Loading branch information
charkour authored Oct 24, 2021
2 parents 51896bf + 218bede commit c723f95
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 30 deletions.
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const useStoreWithUndo = create<StoreState>(
### Middleware Options

```tsx
options: { omit: string[], allowUnchanged: boolean } = { omit: [], allowUnchanged: undefined }
options: { omit?: string[], allowUnchanged?: boolean, historyDepthLimit?: number }
```

#### **Omit fields from being tracked in history**
Expand Down Expand Up @@ -110,6 +110,19 @@ const useStore = create<StoreState>(
);
```

#### **Limit number of states stored**

For performance reasons, you may want to limit the number of previous and future states stored in history. Setting `historyDepthLimit` will limit the number of previous and future states stored in the `zundo` store. By default, no limit is set.

```tsx
const useStore = create<StoreState>(
undoMiddleware(
set => ({ ... }),
{ historyDepthLimit: 100 }
)
);
```

## API

### `undoMiddleware(config: StateCreator<TState>)`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@storybook/addon-links": "^6.3.8",
"@storybook/addons": "^6.3.8",
"@storybook/react": "^6.3.8",
"@testing-library/react-hooks": "7.0.2",
"@types/lodash.isequal": "4.5.5",
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
Expand Down
40 changes: 40 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 27 additions & 14 deletions src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import createVanilla from 'zustand/vanilla';
import createVanilla, { GetState } from 'zustand/vanilla';
import { Options } from './middleware';
import { filterState } from './utils';

Expand Down Expand Up @@ -27,27 +27,17 @@ export const createUndoStore = () => {
futureStates: [],
isUndoHistoryEnabled: true,
undo: () => {
const { prevStates, futureStates, setStore, getStore, options } = get();
if (prevStates.length > 0) {
futureStates.push(filterState(getStore(), options?.omit || []));
const prevState = prevStates.pop();
setStore(prevState);
}
handleStoreUpdates(get, 'undo');
},
redo: () => {
const { prevStates, futureStates, setStore, getStore, options } = get();
if (futureStates.length > 0) {
prevStates.push(filterState(getStore(), options?.omit || []));
const futureState = futureStates.pop();
setStore(futureState);
}
handleStoreUpdates(get, 'redo');
},
clear: () => {
set({ prevStates: [], futureStates: [] });
},
setIsUndoHistoryEnabled: isEnabled => {
const { prevStates, getStore, options } = get();
const currState = filterState(getStore(), options?.omit || []);
const currState = filterState(getStore(), options?.omit);

set({
isUndoHistoryEnabled: isEnabled,
Expand All @@ -60,3 +50,26 @@ export const createUndoStore = () => {
};
});
};

const handleStoreUpdates = (
get: GetState<UndoStoreState>,
action: 'undo' | 'redo'
) => {
const { prevStates, futureStates, setStore, getStore, options } = get();

const isUndo = action === 'undo';
const currentActionStates = isUndo ? prevStates : futureStates;
const otherActionStates = isUndo ? futureStates : prevStates;
const limit = options?.historyDepthLimit;

if (currentActionStates.length > 0) {
// check history limit
if (limit && otherActionStates.length >= limit) {
// pop front
otherActionStates.shift();
}
otherActionStates.push(filterState(getStore(), options?.omit));
const currentStoreState = currentActionStates.pop();
setStore(currentStoreState);
}
};
12 changes: 9 additions & 3 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface Options {
// TODO: improve this type. ignored should only be fields on TState
omit?: string[];
allowUnchanged?: boolean;
historyDepthLimit?: number;
}

// custom zustand middleware to get previous state
Expand Down Expand Up @@ -46,21 +47,26 @@ export const undoMiddleware = <TState extends UndoState>(
});

// Get the last state before updating state
const lastState = filterState({ ...get() }, options?.omit || []);
const lastState = filterState({ ...get() }, options?.omit);

set(args);

// Get the current state after updating state
const currState = filterState({ ...get() }, options?.omit || []);
const currState = filterState({ ...get() }, options?.omit);

// Only store changes if state isn't equal (or option has been set)
const shouldStoreChange =
isUndoHistoryEnabled &&
(!isEqual(lastState, currState) || options?.allowUnchanged);

const limit = options?.historyDepthLimit;

if (shouldStoreChange) {
const prevStates = getState().prevStates;

if (limit && prevStates.length >= limit) {
// pop front
prevStates.shift();
}
setState({
prevStates: [...prevStates, lastState],
setStore: set,
Expand Down
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// TODO: make this a better type
export const filterState = (state: any, ignored: string[]) => {
export const filterState = (state: any, ignored: string[] = []) => {
const filteredState: any = {};
Object.keys(state).forEach((key: string) => {
if (!ignored.includes(key)) {
Expand Down
38 changes: 28 additions & 10 deletions stories/bears.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const meta: Meta = {

export default meta;

interface StoreState extends UndoState {
export interface StoreState extends UndoState {
bears: number;
ignored: number;
increasePopulation: () => void;
Expand All @@ -29,19 +29,25 @@ interface StoreState extends UndoState {
}

// create a store with undo middleware
const useStore = create<StoreState>(
export const useStore = create<StoreState>(
undoMiddleware(
set => ({
(set) => ({
bears: 0,
ignored: 0,
increasePopulation: () =>
set(state => ({ bears: state.bears + 1, ignored: state.ignored + 1 })),
set((state) => ({
bears: state.bears + 1,
ignored: state.ignored + 1,
})),
decreasePopulation: () =>
set(state => ({ bears: state.bears - 1, ignored: state.ignored - 1 })),
doNothing: () => set(state => ({ ...state })),
set((state) => ({
bears: state.bears - 1,
ignored: state.ignored - 1,
})),
doNothing: () => set((state) => ({ ...state })),
removeAllBears: () => set({ bears: 0 }),
}),
{ omit: ['ignored'] }
{ omit: ['ignored'], historyDepthLimit: 10 }
)
);

Expand Down Expand Up @@ -84,15 +90,27 @@ const App = () => {
<br />
<button onClick={clear}>clear</button>
<br />
<button onClick={() => { setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(false) }}>Disable History</button>
<button onClick={() => { setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(true) }}>Enable History</button>
<button
onClick={() => {
setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(false);
}}
>
Disable History
</button>
<button
onClick={() => {
setIsUndoHistoryEnabled && setIsUndoHistoryEnabled(true);
}}
>
Enable History
</button>
<br />
<button onClick={doNothing}>do nothing</button>
</div>
);
};

const Template: Story<{}> = args => <App {...args} />;
const Template: Story<{}> = (args) => <App {...args} />;

// By passing using the Args format for exported stories, you can control the props for a component for reuse in a test
// https://storybook.js.org/docs/react/workflows/unit-testing
Expand Down
2 changes: 1 addition & 1 deletion test/app.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import * as ReactDOM from 'react-dom';
import ReactDOM from 'react-dom';
import { Default as App } from '../stories/bears.stories';

describe('App', () => {
Expand Down
47 changes: 47 additions & 0 deletions test/middleware.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useStore } from '../stories/bears.stories';
import { renderHook, act } from '@testing-library/react-hooks';

describe('zundo store', () => {
const { result, rerender } = renderHook(() => useStore());

test('increment', () => {
for (let i = 0; i < 6; i++) {
expect(result.current.bears).toBe(i);
act(() => {
result.current.increasePopulation();
});
rerender();
expect(result.current.bears).toBe(i + 1);
}
});

test('decrement', () => {
for (let i = 6; i > 0; i--) {
expect(result.current.bears).toBe(i);
act(() => {
result.current.decreasePopulation();
});
rerender();
expect(result.current.bears).toBe(i - 1);
}
});

test('undo', () => {
rerender();
expect(result.current.bears).toBe(0);
act(() => {
result.current.undo?.();
});
rerender();
expect(result.current.bears).toBe(1);
});

test('redo', () => {
expect(result.current.bears).toBe(1);
act(() => {
result.current.redo?.();
});
rerender();
expect(result.current.bears).toBe(0);
});
});

0 comments on commit c723f95

Please sign in to comment.