jotai-state-tree 1.3.5 → 1.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +38 -4
- package/dist/index.mjs +37 -3
- package/dist/react.d.mts +2 -2
- package/dist/react.d.ts +2 -2
- package/dist/react.js +2 -2
- package/dist/react.mjs +1 -1
- package/dist/{tree-BMaFqD3f.d.mts → tree-C2ADWEka.d.mts} +1 -1
- package/dist/{tree-BMaFqD3f.d.ts → tree-C2ADWEka.d.ts} +1 -1
- package/package.json +1 -1
- package/src/__tests__/history_repro.test.ts +100 -0
- package/src/__tests__/react.react.test.tsx +50 -0
- package/src/array.ts +1 -1
- package/src/tree.ts +5 -5
- package/src/undo.ts +38 -1
- package/dist/{chunk-E6MXYAAH.mjs → chunk-K2MLK6B7.mjs} +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { I as ISimpleType, a as IType, b as IIdentifierType, c as IIdentifierNumberType, d as ILiteralType, e as IEnumerationType, f as IFrozenType, M as ModelProperties, g as IModelType, h as MixinConfig, i as IMixin, j as IAnyType, k as IArrayType, l as IMapType, m as IOptionalType, n as IMaybeType, o as IMaybeNullType, p as IUnionType, U as UnionOptions, q as ILateType, r as IAnyModelType, R as ReferenceOptions, s as IReferenceType, t as ISafeReferenceType, u as IRefinementType, v as IDisposer, S as SnapshotIn, w as Instance, x as IReversibleJsonPatch } from './tree-
|
|
2
|
-
export { L as CustomTypeOptions, D as IAnyComplexType, E as IAnyMixin, G as IJsonPatch, z as IMSTArray, A as IMSTMap, y as IStateTreeNode, H as IValidationContext, K as IValidationError, J as IValidationResult, B as ModelInstance, F as ModelSelf, C as SnapshotOut, T as applyPatch, O as applySnapshot, aw as cleanupStaleEntries, ax as clearAllRegistries, aa as clone, aq as cloneDeep, a8 as destroy, a9 as detach, am as findAll, an as findFirst, as as freeze, a2 as getEnv, ag as getGlobalStore, a4 as getIdentifier, af as getMembers, ar as getOrCreatePath, Y as getParent, $ as getParentOfType, a0 as getPath, a1 as getPathParts, av as getRegistryStats, aj as getRelativePath, X as getRoot, N as getSnapshot, ap as getTreeStats, a3 as getType, _ as hasParent, al as haveSameRoot, a5 as isAlive, ak as isAncestor, at as isFrozen, a6 as isRoot, a7 as isStateTreeNode, ao as isValidReference, W as onAction, ay as onLifecycleChange, Q as onPatch, P as onSnapshot, V as recordPatches, ai as resetGlobalStore, ae as resolveIdentifier, ac as resolvePath, ah as setGlobalStore, Z as tryGetParent, ad as tryResolve, au as unfreeze, ab as walk } from './tree-
|
|
1
|
+
import { I as ISimpleType, a as IType, b as IIdentifierType, c as IIdentifierNumberType, d as ILiteralType, e as IEnumerationType, f as IFrozenType, M as ModelProperties, g as IModelType, h as MixinConfig, i as IMixin, j as IAnyType, k as IArrayType, l as IMapType, m as IOptionalType, n as IMaybeType, o as IMaybeNullType, p as IUnionType, U as UnionOptions, q as ILateType, r as IAnyModelType, R as ReferenceOptions, s as IReferenceType, t as ISafeReferenceType, u as IRefinementType, v as IDisposer, S as SnapshotIn, w as Instance, x as IReversibleJsonPatch } from './tree-C2ADWEka.mjs';
|
|
2
|
+
export { L as CustomTypeOptions, D as IAnyComplexType, E as IAnyMixin, G as IJsonPatch, z as IMSTArray, A as IMSTMap, y as IStateTreeNode, H as IValidationContext, K as IValidationError, J as IValidationResult, B as ModelInstance, F as ModelSelf, C as SnapshotOut, T as applyPatch, O as applySnapshot, aw as cleanupStaleEntries, ax as clearAllRegistries, aa as clone, aq as cloneDeep, a8 as destroy, a9 as detach, am as findAll, an as findFirst, as as freeze, a2 as getEnv, ag as getGlobalStore, a4 as getIdentifier, af as getMembers, ar as getOrCreatePath, Y as getParent, $ as getParentOfType, a0 as getPath, a1 as getPathParts, av as getRegistryStats, aj as getRelativePath, X as getRoot, N as getSnapshot, ap as getTreeStats, a3 as getType, _ as hasParent, al as haveSameRoot, a5 as isAlive, ak as isAncestor, at as isFrozen, a6 as isRoot, a7 as isStateTreeNode, ao as isValidReference, W as onAction, ay as onLifecycleChange, Q as onPatch, P as onSnapshot, V as recordPatches, ai as resetGlobalStore, ae as resolveIdentifier, ac as resolvePath, ah as setGlobalStore, Z as tryGetParent, ad as tryResolve, au as unfreeze, ab as walk } from './tree-C2ADWEka.mjs';
|
|
3
3
|
import 'jotai/vanilla/internals';
|
|
4
4
|
import 'jotai';
|
|
5
5
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { I as ISimpleType, a as IType, b as IIdentifierType, c as IIdentifierNumberType, d as ILiteralType, e as IEnumerationType, f as IFrozenType, M as ModelProperties, g as IModelType, h as MixinConfig, i as IMixin, j as IAnyType, k as IArrayType, l as IMapType, m as IOptionalType, n as IMaybeType, o as IMaybeNullType, p as IUnionType, U as UnionOptions, q as ILateType, r as IAnyModelType, R as ReferenceOptions, s as IReferenceType, t as ISafeReferenceType, u as IRefinementType, v as IDisposer, S as SnapshotIn, w as Instance, x as IReversibleJsonPatch } from './tree-
|
|
2
|
-
export { L as CustomTypeOptions, D as IAnyComplexType, E as IAnyMixin, G as IJsonPatch, z as IMSTArray, A as IMSTMap, y as IStateTreeNode, H as IValidationContext, K as IValidationError, J as IValidationResult, B as ModelInstance, F as ModelSelf, C as SnapshotOut, T as applyPatch, O as applySnapshot, aw as cleanupStaleEntries, ax as clearAllRegistries, aa as clone, aq as cloneDeep, a8 as destroy, a9 as detach, am as findAll, an as findFirst, as as freeze, a2 as getEnv, ag as getGlobalStore, a4 as getIdentifier, af as getMembers, ar as getOrCreatePath, Y as getParent, $ as getParentOfType, a0 as getPath, a1 as getPathParts, av as getRegistryStats, aj as getRelativePath, X as getRoot, N as getSnapshot, ap as getTreeStats, a3 as getType, _ as hasParent, al as haveSameRoot, a5 as isAlive, ak as isAncestor, at as isFrozen, a6 as isRoot, a7 as isStateTreeNode, ao as isValidReference, W as onAction, ay as onLifecycleChange, Q as onPatch, P as onSnapshot, V as recordPatches, ai as resetGlobalStore, ae as resolveIdentifier, ac as resolvePath, ah as setGlobalStore, Z as tryGetParent, ad as tryResolve, au as unfreeze, ab as walk } from './tree-
|
|
1
|
+
import { I as ISimpleType, a as IType, b as IIdentifierType, c as IIdentifierNumberType, d as ILiteralType, e as IEnumerationType, f as IFrozenType, M as ModelProperties, g as IModelType, h as MixinConfig, i as IMixin, j as IAnyType, k as IArrayType, l as IMapType, m as IOptionalType, n as IMaybeType, o as IMaybeNullType, p as IUnionType, U as UnionOptions, q as ILateType, r as IAnyModelType, R as ReferenceOptions, s as IReferenceType, t as ISafeReferenceType, u as IRefinementType, v as IDisposer, S as SnapshotIn, w as Instance, x as IReversibleJsonPatch } from './tree-C2ADWEka.js';
|
|
2
|
+
export { L as CustomTypeOptions, D as IAnyComplexType, E as IAnyMixin, G as IJsonPatch, z as IMSTArray, A as IMSTMap, y as IStateTreeNode, H as IValidationContext, K as IValidationError, J as IValidationResult, B as ModelInstance, F as ModelSelf, C as SnapshotOut, T as applyPatch, O as applySnapshot, aw as cleanupStaleEntries, ax as clearAllRegistries, aa as clone, aq as cloneDeep, a8 as destroy, a9 as detach, am as findAll, an as findFirst, as as freeze, a2 as getEnv, ag as getGlobalStore, a4 as getIdentifier, af as getMembers, ar as getOrCreatePath, Y as getParent, $ as getParentOfType, a0 as getPath, a1 as getPathParts, av as getRegistryStats, aj as getRelativePath, X as getRoot, N as getSnapshot, ap as getTreeStats, a3 as getType, _ as hasParent, al as haveSameRoot, a5 as isAlive, ak as isAncestor, at as isFrozen, a6 as isRoot, a7 as isStateTreeNode, ao as isValidReference, W as onAction, ay as onLifecycleChange, Q as onPatch, P as onSnapshot, V as recordPatches, ai as resetGlobalStore, ae as resolveIdentifier, ac as resolvePath, ah as setGlobalStore, Z as tryGetParent, ad as tryResolve, au as unfreeze, ab as walk } from './tree-C2ADWEka.js';
|
|
3
3
|
import 'jotai/vanilla/internals';
|
|
4
4
|
import 'jotai';
|
|
5
5
|
|
package/dist/index.js
CHANGED
|
@@ -565,11 +565,11 @@ var StateTreeNode = class {
|
|
|
565
565
|
return;
|
|
566
566
|
}
|
|
567
567
|
globalStore.set(this.valueAtom, value);
|
|
568
|
+
this.notifySnapshotChange();
|
|
568
569
|
this.notifyPatch(
|
|
569
570
|
{ op: "replace", path: this.$path, value },
|
|
570
571
|
{ op: "replace", path: this.$path, value: oldValue, oldValue }
|
|
571
572
|
);
|
|
572
|
-
this.notifySnapshotChange();
|
|
573
573
|
}
|
|
574
574
|
/** Add a child node */
|
|
575
575
|
addChild(key, child) {
|
|
@@ -671,11 +671,11 @@ var StateTreeNode = class {
|
|
|
671
671
|
/** Notify about a property change (for use by model proxy) */
|
|
672
672
|
notifyPropertyChange(propName, newValue, oldValue) {
|
|
673
673
|
const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
|
|
674
|
+
this.notifySnapshotChange();
|
|
674
675
|
this.notifyPatch(
|
|
675
676
|
{ op: "replace", path, value: newValue },
|
|
676
677
|
{ op: "replace", path, value: oldValue, oldValue }
|
|
677
678
|
);
|
|
678
|
-
this.notifySnapshotChange();
|
|
679
679
|
}
|
|
680
680
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
681
681
|
notifyVolatileChange() {
|
|
@@ -2581,7 +2581,7 @@ var MSTArray = class _MSTArray extends Array {
|
|
|
2581
2581
|
}
|
|
2582
2582
|
const store = getGlobalStore();
|
|
2583
2583
|
store.set(this.node.valueAtom, newArray);
|
|
2584
|
-
this.node.
|
|
2584
|
+
this.node.notifySnapshotChange();
|
|
2585
2585
|
patches.forEach((patch, idx) => {
|
|
2586
2586
|
this.node.notifyPatch(patch, reversePatches[idx]);
|
|
2587
2587
|
});
|
|
@@ -3871,6 +3871,9 @@ var TimeTravelManager = class {
|
|
|
3871
3871
|
this.index = -1;
|
|
3872
3872
|
this.isApplying = false;
|
|
3873
3873
|
this.disposer = null;
|
|
3874
|
+
this.actionDisposer = null;
|
|
3875
|
+
this.pendingRecord = false;
|
|
3876
|
+
this.actionGrouping = false;
|
|
3874
3877
|
this.target = target;
|
|
3875
3878
|
this.maxSnapshots = options.maxSnapshots ?? 50;
|
|
3876
3879
|
this.autoRecord = options.autoRecord ?? false;
|
|
@@ -3882,8 +3885,35 @@ var TimeTravelManager = class {
|
|
|
3882
3885
|
if (node.getRoot().$isApplyingHistory) {
|
|
3883
3886
|
return;
|
|
3884
3887
|
}
|
|
3885
|
-
|
|
3888
|
+
if (isActionRunning()) {
|
|
3889
|
+
this.pendingRecord = true;
|
|
3890
|
+
if (!this.actionGrouping) {
|
|
3891
|
+
this.actionGrouping = true;
|
|
3892
|
+
Promise.resolve().then(() => {
|
|
3893
|
+
if (this.actionGrouping) {
|
|
3894
|
+
this.commitPendingRecord();
|
|
3895
|
+
}
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
} else {
|
|
3899
|
+
this.record();
|
|
3900
|
+
}
|
|
3886
3901
|
});
|
|
3902
|
+
this.actionDisposer = onAction(target, () => {
|
|
3903
|
+
const current = getCurrentAction();
|
|
3904
|
+
if (current && !current.parent) {
|
|
3905
|
+
if (this.actionGrouping) {
|
|
3906
|
+
this.commitPendingRecord();
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
});
|
|
3910
|
+
}
|
|
3911
|
+
}
|
|
3912
|
+
commitPendingRecord() {
|
|
3913
|
+
this.actionGrouping = false;
|
|
3914
|
+
if (this.pendingRecord) {
|
|
3915
|
+
this.pendingRecord = false;
|
|
3916
|
+
this.record();
|
|
3887
3917
|
}
|
|
3888
3918
|
}
|
|
3889
3919
|
get currentIndex() {
|
|
@@ -3949,6 +3979,10 @@ var TimeTravelManager = class {
|
|
|
3949
3979
|
this.disposer();
|
|
3950
3980
|
this.disposer = null;
|
|
3951
3981
|
}
|
|
3982
|
+
if (this.actionDisposer) {
|
|
3983
|
+
this.actionDisposer();
|
|
3984
|
+
this.actionDisposer = null;
|
|
3985
|
+
}
|
|
3952
3986
|
}
|
|
3953
3987
|
};
|
|
3954
3988
|
function createTimeTravelManager(target, options) {
|
package/dist/index.mjs
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
tryResolve,
|
|
60
60
|
unfreeze,
|
|
61
61
|
walk
|
|
62
|
-
} from "./chunk-
|
|
62
|
+
} from "./chunk-K2MLK6B7.mjs";
|
|
63
63
|
|
|
64
64
|
// src/primitives.ts
|
|
65
65
|
function createSimpleType(name, validator, defaultValue) {
|
|
@@ -1590,7 +1590,7 @@ var MSTArray = class _MSTArray extends Array {
|
|
|
1590
1590
|
}
|
|
1591
1591
|
const store = getGlobalStore();
|
|
1592
1592
|
store.set(this.node.valueAtom, newArray);
|
|
1593
|
-
this.node.
|
|
1593
|
+
this.node.notifySnapshotChange();
|
|
1594
1594
|
patches.forEach((patch, idx) => {
|
|
1595
1595
|
this.node.notifyPatch(patch, reversePatches[idx]);
|
|
1596
1596
|
});
|
|
@@ -2880,6 +2880,9 @@ var TimeTravelManager = class {
|
|
|
2880
2880
|
this.index = -1;
|
|
2881
2881
|
this.isApplying = false;
|
|
2882
2882
|
this.disposer = null;
|
|
2883
|
+
this.actionDisposer = null;
|
|
2884
|
+
this.pendingRecord = false;
|
|
2885
|
+
this.actionGrouping = false;
|
|
2883
2886
|
this.target = target;
|
|
2884
2887
|
this.maxSnapshots = options.maxSnapshots ?? 50;
|
|
2885
2888
|
this.autoRecord = options.autoRecord ?? false;
|
|
@@ -2891,8 +2894,35 @@ var TimeTravelManager = class {
|
|
|
2891
2894
|
if (node.getRoot().$isApplyingHistory) {
|
|
2892
2895
|
return;
|
|
2893
2896
|
}
|
|
2894
|
-
|
|
2897
|
+
if (isActionRunning()) {
|
|
2898
|
+
this.pendingRecord = true;
|
|
2899
|
+
if (!this.actionGrouping) {
|
|
2900
|
+
this.actionGrouping = true;
|
|
2901
|
+
Promise.resolve().then(() => {
|
|
2902
|
+
if (this.actionGrouping) {
|
|
2903
|
+
this.commitPendingRecord();
|
|
2904
|
+
}
|
|
2905
|
+
});
|
|
2906
|
+
}
|
|
2907
|
+
} else {
|
|
2908
|
+
this.record();
|
|
2909
|
+
}
|
|
2895
2910
|
});
|
|
2911
|
+
this.actionDisposer = onAction(target, () => {
|
|
2912
|
+
const current = getCurrentAction();
|
|
2913
|
+
if (current && !current.parent) {
|
|
2914
|
+
if (this.actionGrouping) {
|
|
2915
|
+
this.commitPendingRecord();
|
|
2916
|
+
}
|
|
2917
|
+
}
|
|
2918
|
+
});
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
commitPendingRecord() {
|
|
2922
|
+
this.actionGrouping = false;
|
|
2923
|
+
if (this.pendingRecord) {
|
|
2924
|
+
this.pendingRecord = false;
|
|
2925
|
+
this.record();
|
|
2896
2926
|
}
|
|
2897
2927
|
}
|
|
2898
2928
|
get currentIndex() {
|
|
@@ -2958,6 +2988,10 @@ var TimeTravelManager = class {
|
|
|
2958
2988
|
this.disposer();
|
|
2959
2989
|
this.disposer = null;
|
|
2960
2990
|
}
|
|
2991
|
+
if (this.actionDisposer) {
|
|
2992
|
+
this.actionDisposer();
|
|
2993
|
+
this.actionDisposer = null;
|
|
2994
|
+
}
|
|
2961
2995
|
}
|
|
2962
2996
|
};
|
|
2963
2997
|
function createTimeTravelManager(target, options) {
|
package/dist/react.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { ComponentType, FC, ReactNode } from 'react';
|
|
2
|
-
import { ag as getGlobalStore } from './tree-
|
|
3
|
-
export { az as hasStateTreeNode } from './tree-
|
|
2
|
+
import { ag as getGlobalStore } from './tree-C2ADWEka.mjs';
|
|
3
|
+
export { az as hasStateTreeNode } from './tree-C2ADWEka.mjs';
|
|
4
4
|
import 'jotai/vanilla/internals';
|
|
5
5
|
import 'jotai';
|
|
6
6
|
|
package/dist/react.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { ComponentType, FC, ReactNode } from 'react';
|
|
2
|
-
import { ag as getGlobalStore } from './tree-
|
|
3
|
-
export { az as hasStateTreeNode } from './tree-
|
|
2
|
+
import { ag as getGlobalStore } from './tree-C2ADWEka.js';
|
|
3
|
+
export { az as hasStateTreeNode } from './tree-C2ADWEka.js';
|
|
4
4
|
import 'jotai/vanilla/internals';
|
|
5
5
|
import 'jotai';
|
|
6
6
|
|
package/dist/react.js
CHANGED
|
@@ -176,11 +176,11 @@ var StateTreeNode = class {
|
|
|
176
176
|
return;
|
|
177
177
|
}
|
|
178
178
|
globalStore.set(this.valueAtom, value);
|
|
179
|
+
this.notifySnapshotChange();
|
|
179
180
|
this.notifyPatch(
|
|
180
181
|
{ op: "replace", path: this.$path, value },
|
|
181
182
|
{ op: "replace", path: this.$path, value: oldValue, oldValue }
|
|
182
183
|
);
|
|
183
|
-
this.notifySnapshotChange();
|
|
184
184
|
}
|
|
185
185
|
/** Add a child node */
|
|
186
186
|
addChild(key, child) {
|
|
@@ -282,11 +282,11 @@ var StateTreeNode = class {
|
|
|
282
282
|
/** Notify about a property change (for use by model proxy) */
|
|
283
283
|
notifyPropertyChange(propName, newValue, oldValue) {
|
|
284
284
|
const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
|
|
285
|
+
this.notifySnapshotChange();
|
|
285
286
|
this.notifyPatch(
|
|
286
287
|
{ op: "replace", path, value: newValue },
|
|
287
288
|
{ op: "replace", path, value: oldValue, oldValue }
|
|
288
289
|
);
|
|
289
|
-
this.notifySnapshotChange();
|
|
290
290
|
}
|
|
291
291
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
292
292
|
notifyVolatileChange() {
|
package/dist/react.mjs
CHANGED
|
@@ -379,7 +379,7 @@ declare class StateTreeNode implements IStateTreeNode {
|
|
|
379
379
|
/** Notify patch listeners */
|
|
380
380
|
notifyPatch(patch: IJsonPatch, reversePatch: IReversibleJsonPatch): void;
|
|
381
381
|
/** Notify snapshot listeners */
|
|
382
|
-
|
|
382
|
+
notifySnapshotChange(): void;
|
|
383
383
|
/** Notify about a property change (for use by model proxy) */
|
|
384
384
|
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown): void;
|
|
385
385
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
@@ -379,7 +379,7 @@ declare class StateTreeNode implements IStateTreeNode {
|
|
|
379
379
|
/** Notify patch listeners */
|
|
380
380
|
notifyPatch(patch: IJsonPatch, reversePatch: IReversibleJsonPatch): void;
|
|
381
381
|
/** Notify snapshot listeners */
|
|
382
|
-
|
|
382
|
+
notifySnapshotChange(): void;
|
|
383
383
|
/** Notify about a property change (for use by model proxy) */
|
|
384
384
|
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown): void;
|
|
385
385
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { types, getSnapshot, createUndoManager, createTimeTravelManager } from '../index';
|
|
3
|
+
|
|
4
|
+
const Todo = types
|
|
5
|
+
.model('Todo', {
|
|
6
|
+
id: types.identifier,
|
|
7
|
+
title: types.string,
|
|
8
|
+
done: types.optional(types.boolean, false),
|
|
9
|
+
})
|
|
10
|
+
.actions((self) => ({
|
|
11
|
+
toggle() {
|
|
12
|
+
self.done = !self.done;
|
|
13
|
+
},
|
|
14
|
+
setTitle(title: string) {
|
|
15
|
+
self.title = title;
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
const TodoStore = types
|
|
20
|
+
.model('TodoStore', {
|
|
21
|
+
todos: types.optional(types.array(Todo), []),
|
|
22
|
+
filter: types.optional(types.string, 'all'),
|
|
23
|
+
})
|
|
24
|
+
.actions((self) => ({
|
|
25
|
+
addTodo(title: string) {
|
|
26
|
+
if (!title.trim()) return;
|
|
27
|
+
self.todos.push({
|
|
28
|
+
id: Math.random().toString(36).substring(2, 9),
|
|
29
|
+
title,
|
|
30
|
+
done: false,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
removeTodo(id: string) {
|
|
34
|
+
const item = self.todos.find((t) => t.id === id);
|
|
35
|
+
if (item) {
|
|
36
|
+
self.todos.remove(item);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
clearCompleted() {
|
|
40
|
+
const completed = self.todos.filter((t) => t.done);
|
|
41
|
+
completed.forEach((item) => self.todos.remove(item));
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
describe("Todo app history integration", () => {
|
|
46
|
+
it("should match todo example time travel flow with action grouping", () => {
|
|
47
|
+
const store = TodoStore.create({
|
|
48
|
+
todos: [
|
|
49
|
+
{ id: '1', title: 'Learn jotai-state-tree', done: true },
|
|
50
|
+
{ id: '2', title: 'Explore Vite templates', done: false },
|
|
51
|
+
{ id: '3', title: 'Build clean minimalist UIs', done: false },
|
|
52
|
+
]
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const undoManager = createUndoManager(store, { maxHistoryLength: 50 });
|
|
56
|
+
const timeTravel = createTimeTravelManager(store, { maxSnapshots: 50, autoRecord: true });
|
|
57
|
+
|
|
58
|
+
expect(timeTravel.snapshotCount).toBe(1);
|
|
59
|
+
expect(timeTravel.currentIndex).toBe(0);
|
|
60
|
+
|
|
61
|
+
// 1. Add todo
|
|
62
|
+
store.addTodo("New Task");
|
|
63
|
+
expect(store.todos.length).toBe(4);
|
|
64
|
+
expect(timeTravel.snapshotCount).toBe(2);
|
|
65
|
+
expect(timeTravel.currentIndex).toBe(1);
|
|
66
|
+
|
|
67
|
+
// 2. Toggle todo done
|
|
68
|
+
const newTodo = store.todos[3];
|
|
69
|
+
newTodo.toggle();
|
|
70
|
+
expect(newTodo.done).toBe(true);
|
|
71
|
+
expect(timeTravel.snapshotCount).toBe(3);
|
|
72
|
+
expect(timeTravel.currentIndex).toBe(2);
|
|
73
|
+
|
|
74
|
+
// 3. Clear completed (which should remove Learn jotai-state-tree and New Task in a single action)
|
|
75
|
+
store.clearCompleted();
|
|
76
|
+
expect(store.todos.length).toBe(2);
|
|
77
|
+
// Should group the multiple removals in clearCompleted into a single snapshot update
|
|
78
|
+
expect(timeTravel.snapshotCount).toBe(4);
|
|
79
|
+
expect(timeTravel.currentIndex).toBe(3);
|
|
80
|
+
|
|
81
|
+
// 4. Undo clearCompleted
|
|
82
|
+
undoManager.undo();
|
|
83
|
+
expect(store.todos.length).toBe(4);
|
|
84
|
+
// Time travel should not have recorded new snapshots during undo
|
|
85
|
+
expect(timeTravel.snapshotCount).toBe(4);
|
|
86
|
+
expect(timeTravel.currentIndex).toBe(3);
|
|
87
|
+
|
|
88
|
+
// 5. Time travel back 2 steps
|
|
89
|
+
timeTravel.goBack(); // Back to state after toggle
|
|
90
|
+
expect(store.todos.length).toBe(4);
|
|
91
|
+
expect(store.todos[3].done).toBe(true);
|
|
92
|
+
|
|
93
|
+
timeTravel.goBack(); // Back to state after addTodo
|
|
94
|
+
expect(store.todos.length).toBe(4);
|
|
95
|
+
expect(store.todos[3].done).toBe(false);
|
|
96
|
+
|
|
97
|
+
undoManager.dispose();
|
|
98
|
+
timeTravel.dispose();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
clearAllRegistries,
|
|
17
17
|
resetGlobalStore,
|
|
18
18
|
getRegistryStats,
|
|
19
|
+
createUndoManager,
|
|
20
|
+
createTimeTravelManager,
|
|
19
21
|
} from "../index";
|
|
20
22
|
|
|
21
23
|
import {
|
|
@@ -585,6 +587,54 @@ describe("React Integration", () => {
|
|
|
585
587
|
expect(screen.getByTestId("snapshot").textContent).toBe("20");
|
|
586
588
|
});
|
|
587
589
|
});
|
|
590
|
+
|
|
591
|
+
it("should update when undo/redo or time travel are executed", async () => {
|
|
592
|
+
const counter = CounterModel.create({ count: 10 });
|
|
593
|
+
const undoManager = createUndoManager(counter);
|
|
594
|
+
const timeTravel = createTimeTravelManager(counter, { autoRecord: true });
|
|
595
|
+
|
|
596
|
+
function SnapshotDisplay({ store }: { store: typeof counter }) {
|
|
597
|
+
const snapshot = useSnapshot<{ count: number }>(store);
|
|
598
|
+
return <div data-testid="snapshot">{snapshot.count}</div>;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
render(<SnapshotDisplay store={counter} />);
|
|
602
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("10");
|
|
603
|
+
|
|
604
|
+
act(() => {
|
|
605
|
+
counter.setCount(20);
|
|
606
|
+
});
|
|
607
|
+
await waitFor(() => {
|
|
608
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("20");
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// Undo
|
|
612
|
+
act(() => {
|
|
613
|
+
undoManager.undo();
|
|
614
|
+
});
|
|
615
|
+
await waitFor(() => {
|
|
616
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("10");
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Redo
|
|
620
|
+
act(() => {
|
|
621
|
+
undoManager.redo();
|
|
622
|
+
});
|
|
623
|
+
await waitFor(() => {
|
|
624
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("20");
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Time travel goBack
|
|
628
|
+
act(() => {
|
|
629
|
+
timeTravel.goBack();
|
|
630
|
+
});
|
|
631
|
+
await waitFor(() => {
|
|
632
|
+
expect(screen.getByTestId("snapshot").textContent).toBe("10");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
undoManager.dispose();
|
|
636
|
+
timeTravel.dispose();
|
|
637
|
+
});
|
|
588
638
|
});
|
|
589
639
|
|
|
590
640
|
// ============================================================================
|
package/src/array.ts
CHANGED
|
@@ -374,7 +374,7 @@ class MSTArray<T> extends Array<T> implements IMSTArray<T> {
|
|
|
374
374
|
// Update the node's value silently
|
|
375
375
|
const store = getGlobalStore();
|
|
376
376
|
store.set(this.node.valueAtom, newArray);
|
|
377
|
-
this.node.
|
|
377
|
+
this.node.notifySnapshotChange();
|
|
378
378
|
|
|
379
379
|
// Notify patch listeners
|
|
380
380
|
patches.forEach((patch, idx) => {
|
package/src/tree.ts
CHANGED
|
@@ -285,14 +285,14 @@ export class StateTreeNode implements IStateTreeNode {
|
|
|
285
285
|
}
|
|
286
286
|
globalStore.set(this.valueAtom, value);
|
|
287
287
|
|
|
288
|
+
// Notify snapshot listeners (bubble up to root)
|
|
289
|
+
this.notifySnapshotChange();
|
|
290
|
+
|
|
288
291
|
// Notify patch listeners
|
|
289
292
|
this.notifyPatch(
|
|
290
293
|
{ op: "replace", path: this.$path, value },
|
|
291
294
|
{ op: "replace", path: this.$path, value: oldValue, oldValue },
|
|
292
295
|
);
|
|
293
|
-
|
|
294
|
-
// Notify snapshot listeners (bubble up to root)
|
|
295
|
-
this.notifySnapshotChange();
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/** Add a child node */
|
|
@@ -406,7 +406,7 @@ export class StateTreeNode implements IStateTreeNode {
|
|
|
406
406
|
}
|
|
407
407
|
|
|
408
408
|
/** Notify snapshot listeners */
|
|
409
|
-
|
|
409
|
+
notifySnapshotChange() {
|
|
410
410
|
let current: StateTreeNode | null = this;
|
|
411
411
|
while (current) {
|
|
412
412
|
current.invalidateSnapshot();
|
|
@@ -419,11 +419,11 @@ export class StateTreeNode implements IStateTreeNode {
|
|
|
419
419
|
/** Notify about a property change (for use by model proxy) */
|
|
420
420
|
notifyPropertyChange(propName: string, newValue: unknown, oldValue: unknown) {
|
|
421
421
|
const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
|
|
422
|
+
this.notifySnapshotChange();
|
|
422
423
|
this.notifyPatch(
|
|
423
424
|
{ op: "replace", path, value: newValue },
|
|
424
425
|
{ op: "replace", path, value: oldValue, oldValue },
|
|
425
426
|
);
|
|
426
|
-
this.notifySnapshotChange();
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
package/src/undo.ts
CHANGED
|
@@ -360,7 +360,10 @@ class TimeTravelManager implements ITimeTravelManager {
|
|
|
360
360
|
private maxSnapshots: number;
|
|
361
361
|
private isApplying: boolean = false;
|
|
362
362
|
private disposer: IDisposer | null = null;
|
|
363
|
+
private actionDisposer: IDisposer | null = null;
|
|
363
364
|
private autoRecord: boolean;
|
|
365
|
+
private pendingRecord: boolean = false;
|
|
366
|
+
private actionGrouping: boolean = false;
|
|
364
367
|
|
|
365
368
|
constructor(
|
|
366
369
|
target: unknown,
|
|
@@ -384,11 +387,41 @@ class TimeTravelManager implements ITimeTravelManager {
|
|
|
384
387
|
if (node.getRoot().$isApplyingHistory) {
|
|
385
388
|
return;
|
|
386
389
|
}
|
|
387
|
-
|
|
390
|
+
|
|
391
|
+
if (isActionRunning()) {
|
|
392
|
+
this.pendingRecord = true;
|
|
393
|
+
if (!this.actionGrouping) {
|
|
394
|
+
this.actionGrouping = true;
|
|
395
|
+
Promise.resolve().then(() => {
|
|
396
|
+
if (this.actionGrouping) {
|
|
397
|
+
this.commitPendingRecord();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
this.record();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
this.actionDisposer = onAction(target, () => {
|
|
407
|
+
const current = getCurrentAction();
|
|
408
|
+
if (current && !current.parent) {
|
|
409
|
+
if (this.actionGrouping) {
|
|
410
|
+
this.commitPendingRecord();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
388
413
|
});
|
|
389
414
|
}
|
|
390
415
|
}
|
|
391
416
|
|
|
417
|
+
private commitPendingRecord() {
|
|
418
|
+
this.actionGrouping = false;
|
|
419
|
+
if (this.pendingRecord) {
|
|
420
|
+
this.pendingRecord = false;
|
|
421
|
+
this.record();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
392
425
|
get currentIndex(): number {
|
|
393
426
|
return this.index;
|
|
394
427
|
}
|
|
@@ -469,6 +502,10 @@ class TimeTravelManager implements ITimeTravelManager {
|
|
|
469
502
|
this.disposer();
|
|
470
503
|
this.disposer = null;
|
|
471
504
|
}
|
|
505
|
+
if (this.actionDisposer) {
|
|
506
|
+
this.actionDisposer();
|
|
507
|
+
this.actionDisposer = null;
|
|
508
|
+
}
|
|
472
509
|
}
|
|
473
510
|
}
|
|
474
511
|
|
|
@@ -134,11 +134,11 @@ var StateTreeNode = class {
|
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
globalStore.set(this.valueAtom, value);
|
|
137
|
+
this.notifySnapshotChange();
|
|
137
138
|
this.notifyPatch(
|
|
138
139
|
{ op: "replace", path: this.$path, value },
|
|
139
140
|
{ op: "replace", path: this.$path, value: oldValue, oldValue }
|
|
140
141
|
);
|
|
141
|
-
this.notifySnapshotChange();
|
|
142
142
|
}
|
|
143
143
|
/** Add a child node */
|
|
144
144
|
addChild(key, child) {
|
|
@@ -240,11 +240,11 @@ var StateTreeNode = class {
|
|
|
240
240
|
/** Notify about a property change (for use by model proxy) */
|
|
241
241
|
notifyPropertyChange(propName, newValue, oldValue) {
|
|
242
242
|
const path = this.$path ? `${this.$path}/${propName}` : `/${propName}`;
|
|
243
|
+
this.notifySnapshotChange();
|
|
243
244
|
this.notifyPatch(
|
|
244
245
|
{ op: "replace", path, value: newValue },
|
|
245
246
|
{ op: "replace", path, value: oldValue, oldValue }
|
|
246
247
|
);
|
|
247
|
-
this.notifySnapshotChange();
|
|
248
248
|
}
|
|
249
249
|
/** Notify about a volatile state change (triggers snapshot listeners without patches) */
|
|
250
250
|
notifyVolatileChange() {
|