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 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-BMaFqD3f.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-BMaFqD3f.mjs';
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-BMaFqD3f.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-BMaFqD3f.js';
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.invalidateSnapshot();
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
- this.record();
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-E6MXYAAH.mjs";
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.invalidateSnapshot();
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
- this.record();
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-BMaFqD3f.mjs';
3
- export { az as hasStateTreeNode } from './tree-BMaFqD3f.mjs';
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-BMaFqD3f.js';
3
- export { az as hasStateTreeNode } from './tree-BMaFqD3f.js';
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
@@ -9,7 +9,7 @@ import {
9
9
  onPatch,
10
10
  onSnapshot,
11
11
  setActiveTrackingFn
12
- } from "./chunk-E6MXYAAH.mjs";
12
+ } from "./chunk-K2MLK6B7.mjs";
13
13
 
14
14
  // src/react.ts
15
15
  import React, {
@@ -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
- private notifySnapshotChange;
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
- private notifySnapshotChange;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jotai-state-tree",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "description": "MobX-State-Tree API compatible library powered by Jotai",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -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.invalidateSnapshot();
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
- private notifySnapshotChange() {
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
- this.record();
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() {