Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions src/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { clonedValues } from './index';
import type { Key } from './type';

export interface Action<T = any> {
type: string;
payload: T;
}

export class History<T extends object> {
private history: T[] = [];
private currentIndex = -1;

constructor(private state: T) {
this.record(state);
}

public record(state: T): void {
if (this.currentIndex < this.history.length - 1) {
this.history.splice(this.currentIndex + 1);
}
this.history.push(clonedValues(state));
this.currentIndex++;
}

public undo(): T | null {
if (this.currentIndex > 0) {
this.currentIndex--;
return clonedValues(this.history[this.currentIndex]);
}
return null;
}

public redo(): T | null {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
return clonedValues(this.history[this.currentIndex]);
}
return null;
}

public getCurrentState(): T | null {
if (this.currentIndex >= 0 && this.currentIndex < this.history.length) {
return clonedValues(this.history[this.currentIndex]);
}
return null;
}
}
46 changes: 46 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { trackPerformance, trackMemory } from './performance';
import type { Key, TPath, WatchCallback, WatchObject } from './type';
import { connectDevTools, sendToDevTools } from './devtools';
import { History, Action } from './history';

const isObject = (value: any): value is object => {
return value !== null && typeof value === 'object';
Expand Down Expand Up @@ -68,6 +69,8 @@ const getIdPath = (paths: Key[]): string => {
const AnyState = function <T extends object>(initialized: T) {
const pristineState = clonedValues(initialized);
let isBatching = false;
let isRestoringHistory = false;
const history = new History(clonedValues(initialized));
type Watcher<V = any> = {
key: string;
paths: Key[];
Expand Down Expand Up @@ -127,6 +130,9 @@ const AnyState = function <T extends object>(initialized: T) {
}
});
sendToDevTools('setState', state);
if (state && !isRestoringHistory) {
history.record(state);
}
});

const reset = () => {
Expand Down Expand Up @@ -158,6 +164,9 @@ const AnyState = function <T extends object>(initialized: T) {

setIn(state, paths, value);
sendToDevTools(`setItem: ${key.toString()}`, state);
if (state && !isRestoringHistory) {
history.record(state);
}
});

const getItem = trackPerformance('getItem', <V = any>(path: TPath): V | undefined => {
Expand Down Expand Up @@ -255,6 +264,38 @@ const AnyState = function <T extends object>(initialized: T) {
throw new Error('watch: first argument must be a string path or an object with path-callback pairs');
};

const undo = () => {
const prevState = history.undo();
if (prevState) {
isRestoringHistory = true;
setState(prevState);
isRestoringHistory = false;
}
};

const redo = () => {
const nextState = history.redo();
if (nextState) {
isRestoringHistory = true;
setState(nextState);
isRestoringHistory = false;
}
};

const createSnapshot = () => {
return JSON.stringify(state);
};

const restoreSnapshot = (snapshot: string) => {
setState(JSON.parse(snapshot));
};

const replayAction = (action: Action) => {
if (action.type === 'SET_ITEM') {
setItem(action.payload.key, action.payload.value);
}
};

return {
setState,
setItem,
Expand All @@ -265,6 +306,11 @@ const AnyState = function <T extends object>(initialized: T) {
logMemoryUsage,
batch,
atomic,
undo,
redo,
createSnapshot,
restoreSnapshot,
replayAction,
};
};

Expand Down
101 changes: 101 additions & 0 deletions test/test-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
var assert = require('assert');
var { createStore } = require('../dist/index.js');

describe('History', function () {
it('should undo and redo state changes', function () {
const state = createStore({
x: 0,
y: {
z: 1,
},
});

state.setItem('x', 10);
state.setItem('y.z', 20);

assert.deepEqual(state.getState(), {
x: 10,
y: {
z: 20,
},
});

state.undo();
assert.deepEqual(state.getState(), {
x: 10,
y: {
z: 1,
},
});

state.undo();
assert.deepEqual(state.getState(), {
x: 0,
y: {
z: 1,
},
});

state.redo();
assert.deepEqual(state.getState(), {
x: 10,
y: {
z: 1,
},
});
});
});

describe('Snapshots', function () {
it('should create and restore snapshots', function () {
const state = createStore({
x: 0,
y: {
z: 1,
},
});

state.setItem('x', 10);
state.setItem('y.z', 20);

const snapshot = state.createSnapshot();

state.setItem('x', 100);
state.setItem('y.z', 200);

assert.deepEqual(state.getState(), {
x: 100,
y: {
z: 200,
},
});

state.restoreSnapshot(snapshot);

assert.deepEqual(state.getState(), {
x: 10,
y: {
z: 20,
},
});
});
});

describe('Action Replay', function () {
it('should replay actions', function () {
const state = createStore({
x: 0,
});

const actions = [
{ type: 'SET_ITEM', payload: { key: 'x', value: 10 } },
{ type: 'SET_ITEM', payload: { key: 'x', value: 20 } },
];

actions.forEach((action) => {
state.replayAction(action);
});

assert.deepEqual(state.getState(), { x: 20 });
});
});