diff --git a/readme.md b/readme.md index 18708f3f..e972cd80 100644 --- a/readme.md +++ b/readme.md @@ -293,6 +293,21 @@ const state = proxyWithComputed({ }) ``` +#### `proxyWithHistory` util + +This is a utility function to create a proxy with snapshot history. + +```js +const state = proxyWithHistory({ count: 0 }) +console.log(state.value) // ---> { count: 0 } +state.value.count += 1 +console.log(state.value) // ---> { count: 1 } +state.undo() +console.log(state.value) // ---> { count: 0 } +state.redo() +console.log(state.value) // ---> { count: 1 } +``` + #### Recipes Valtio is unopinionated about organizing state. diff --git a/src/utils.ts b/src/utils.ts index e790d33c..0375988f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import { createProxy as createProxyToCompare, isChanged } from 'proxy-compare' -import { proxy, subscribe, snapshot } from './vanilla' +import { proxy, subscribe, snapshot, ref } from './vanilla' import type { DeepResolveType } from './vanilla' /** @@ -357,3 +357,69 @@ export const watch = (callback: WatchCallback): (() => void) => { return wrappedCleanup } + +/** + * proxyWithHistory + * + * This creates a new proxy with history support. + * It includes following properties: + * - value: any value (does not have to be an object) + * - history: an array holding the history of snapshots + * - historyIndex: the history index to the current snapshot + * - canUndo: a function to return true if undo is available + * - undo: a function to go back hisotry + * - canRedo: a function to return true if redo is available + * - redo: a function to go forward history + * - saveHistory: a function to save history + * + * [Notes] + * Suspense/promise is not supported. + * + * @example + * import { proxyWithHistory } from 'valtio/utils' + * const state = proxyWithHistory({ + * count: 1, + * }) + */ +export const proxyWithHistory = (initialValue: V) => { + const proxyObject = proxy({ + value: initialValue, + history: ref({ + wip: initialValue, // to avoid infinite loop + snapshots: [] as V[], + index: -1, + }), + canUndo: () => proxyObject.history.index > 0, + undo: () => { + if (proxyObject.canUndo()) { + proxyObject.value = proxyObject.history.wip = + proxyObject.history.snapshots[--proxyObject.history.index] + } + }, + canRedo: () => + proxyObject.history.index < proxyObject.history.snapshots.length - 1, + redo: () => { + if (proxyObject.canRedo()) { + proxyObject.value = proxyObject.history.wip = + proxyObject.history.snapshots[++proxyObject.history.index] + } + }, + saveHistory: () => { + proxyObject.history.snapshots.splice(proxyObject.history.index + 1) + proxyObject.history.snapshots.push(snapshot(proxyObject).value as V) + ++proxyObject.history.index + }, + }) + subscribe(proxyObject, (ops) => { + if ( + ops.some( + (op) => + op[1][0] === 'value' && + (op[0] !== 'set' || op[2] !== proxyObject.history.wip) + ) + ) { + proxyObject.saveHistory() + } + }) + return proxyObject +} diff --git a/tests/history.test.tsx b/tests/history.test.tsx new file mode 100644 index 00000000..5db95eac --- /dev/null +++ b/tests/history.test.tsx @@ -0,0 +1,94 @@ +import React, { StrictMode } from 'react' +import { fireEvent, render } from '@testing-library/react' +import { useSnapshot } from '../src/index' +import { proxyWithHistory } from '../src/utils' + +it('simple count', async () => { + const state = proxyWithHistory(0) + + const Counter: React.FC = () => { + const snap = useSnapshot(state) + return ( + <> +
count: {snap.value}
+ + + + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('inc')) + await findByText('count: 1') + + fireEvent.click(getByText('inc')) + await findByText('count: 2') + + fireEvent.click(getByText('inc')) + await findByText('count: 3') + + fireEvent.click(getByText('undo')) + await findByText('count: 2') + + fireEvent.click(getByText('redo')) + await findByText('count: 3') + + fireEvent.click(getByText('undo')) + await findByText('count: 2') + + fireEvent.click(getByText('undo')) + await findByText('count: 1') +}) + +it('count in object', async () => { + const state = proxyWithHistory({ count: 0 }) + + const Counter: React.FC = () => { + const snap = useSnapshot(state) + return ( + <> +
count: {snap.value.count}
+ + + + + ) + } + + const { getByText, findByText } = render( + + + + ) + + await findByText('count: 0') + + fireEvent.click(getByText('inc')) + await findByText('count: 1') + + fireEvent.click(getByText('inc')) + await findByText('count: 2') + + fireEvent.click(getByText('inc')) + await findByText('count: 3') + + fireEvent.click(getByText('undo')) + await findByText('count: 2') + + fireEvent.click(getByText('redo')) + await findByText('count: 3') + + fireEvent.click(getByText('undo')) + await findByText('count: 2') + + fireEvent.click(getByText('undo')) + await findByText('count: 1') +})