@signaltree/core 6.2.4 → 6.3.0

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/README.md CHANGED
@@ -148,6 +148,46 @@ $.users.setAll(usersFromApi); // Replace all
148
148
  const user = entityMap()[123]; // Requires intermediate object
149
149
  ```
150
150
 
151
+ ### Notification Batching
152
+
153
+ SignalTree automatically batches _notification delivery_ to subscribers and change detection to the end of the current microtask. This prevents render thrashing when multiple values are updated together and preserves immediate read-after-write semantics (values update synchronously, notifications are deferred).
154
+
155
+ **Example**
156
+
157
+ ```typescript
158
+ // Multiple updates in the same microtask are coalesced into a single notification
159
+ tree.$.form.name.set('Alice');
160
+ tree.$.form.email.set('alice@example.com');
161
+ tree.$.form.submitted.set(true);
162
+ // → Subscribers are notified once at the end of the microtask with final values
163
+ ```
164
+
165
+ **Testing**
166
+
167
+ When tests need synchronous notification delivery, use `flushSync()`:
168
+
169
+ ```typescript
170
+ import { getPathNotifier } from '@signaltree/core';
171
+
172
+ it('updates state', () => {
173
+ tree.$.count.set(5);
174
+ getPathNotifier().flushSync();
175
+ expect(subscriber).toHaveBeenCalledWith(5, 0);
176
+ });
177
+ ```
178
+
179
+ Alternatively, await a microtask (`await Promise.resolve()`) to allow the automatic flush to occur.
180
+
181
+ **Opting out**
182
+
183
+ To disable automatic microtask batching for a specific tree instance:
184
+
185
+ ```typescript
186
+ const tree = signalTree(initialState, { batching: false });
187
+ ```
188
+
189
+ Use this only for rare cases that truly require synchronous notifications (most apps should keep batching enabled).
190
+
151
191
  ## Quick start
152
192
 
153
193
  ### Installation
@@ -1,5 +1,5 @@
1
1
  import { snapshotState } from '../../lib/utils.js';
2
- import { deepClone, deepEqual } from './utils.js';
2
+ import { deepEqual, deepClone } from './utils.js';
3
3
 
4
4
  class TimeTravelManager {
5
5
  tree;
@@ -24,7 +24,7 @@ class TimeTravelManager {
24
24
  };
25
25
  this.addEntry('INIT', this.tree());
26
26
  }
27
- addEntry(action, state, payload) {
27
+ addEntry(action, state, payload, provisional = false) {
28
28
  if (this.currentIndex < this.history.length - 1) {
29
29
  this.history = this.history.slice(0, this.currentIndex + 1);
30
30
  }
@@ -43,6 +43,12 @@ class TimeTravelManager {
43
43
  payload
44
44
  })
45
45
  };
46
+ if (provisional) entry.__provisional = true;
47
+ const last = this.history[this.history.length - 1];
48
+ if (last && deepEqual(last.state, entry.state)) {
49
+ if (last.__provisional) delete last.__provisional;
50
+ return;
51
+ }
46
52
  this.history.push(entry);
47
53
  this.currentIndex = this.history.length - 1;
48
54
  if (this.history.length > this.maxHistorySize) {
@@ -59,6 +65,20 @@ class TimeTravelManager {
59
65
  this.restoreState(entry.state);
60
66
  return true;
61
67
  }
68
+ finalizeProvisional(state) {
69
+ const last = this.history[this.history.length - 1];
70
+ if (last && last.__provisional) {
71
+ if (deepEqual(last.state, state)) {
72
+ delete last.__provisional;
73
+ return;
74
+ }
75
+ last.state = deepClone(state);
76
+ last.timestamp = Date.now();
77
+ delete last.__provisional;
78
+ return;
79
+ }
80
+ this.addEntry('update', state);
81
+ }
62
82
  redo() {
63
83
  if (!this.canRedo()) {
64
84
  return false;
@@ -143,6 +163,22 @@ function timeTravel(config = {}) {
143
163
  isRestoring = false;
144
164
  }
145
165
  });
166
+ try {
167
+ const req = globalThis['require'];
168
+ if (typeof req === 'function') {
169
+ const {
170
+ getPathNotifier
171
+ } = req('../../lib/path-notifier');
172
+ const notifier = getPathNotifier();
173
+ if (notifier && typeof notifier.onFlush === 'function') {
174
+ notifier.onFlush(() => {
175
+ if (isRestoring) return;
176
+ const afterState = originalTreeCall();
177
+ timeTravelManager.addEntry('batch', afterState);
178
+ });
179
+ }
180
+ }
181
+ } catch {}
146
182
  const enhancedTree = function (...args) {
147
183
  if (args.length === 0) {
148
184
  return originalTreeCall();
@@ -42,6 +42,8 @@ function createEntitySignal(config, pathNotifier, basePath) {
42
42
  }
43
43
  return node;
44
44
  }
45
+ const whereCache = new WeakMap();
46
+ const findCache = new WeakMap();
45
47
  const api = {
46
48
  byId(id) {
47
49
  const entity = storage.get(id);
@@ -74,10 +76,18 @@ function createEntitySignal(config, pathNotifier, basePath) {
74
76
  return computed(() => countSignal() === 0);
75
77
  },
76
78
  where(predicate) {
77
- return computed(() => allSignal().filter(predicate));
79
+ const cached = whereCache.get(predicate);
80
+ if (cached) return cached;
81
+ const s = computed(() => allSignal().filter(predicate));
82
+ whereCache.set(predicate, s);
83
+ return s;
78
84
  },
79
85
  find(predicate) {
80
- return computed(() => allSignal().find(predicate));
86
+ const cached = findCache.get(predicate);
87
+ if (cached) return cached;
88
+ const s = computed(() => allSignal().find(predicate));
89
+ findCache.set(predicate, s);
90
+ return s;
81
91
  },
82
92
  addOne(entity, opts) {
83
93
  const id = opts?.selectId?.(entity) ?? selectId(entity);
@@ -1,6 +1,20 @@
1
1
  class PathNotifier {
2
2
  subscribers = new Map();
3
3
  interceptors = new Map();
4
+ batchingEnabled = true;
5
+ pendingFlush = false;
6
+ pending = new Map();
7
+ firstValues = new Map();
8
+ flushCallbacks = new Set();
9
+ constructor(options) {
10
+ if (options && options.batching === false) this.batchingEnabled = false;
11
+ }
12
+ setBatchingEnabled(enabled) {
13
+ this.batchingEnabled = enabled;
14
+ }
15
+ isBatchingEnabled() {
16
+ return this.batchingEnabled;
17
+ }
4
18
  subscribe(pattern, handler) {
5
19
  if (!this.subscribers.has(pattern)) {
6
20
  this.subscribers.set(pattern, new Set());
@@ -34,6 +48,26 @@ class PathNotifier {
34
48
  };
35
49
  }
36
50
  notify(path, value, prev) {
51
+ if (!this.batchingEnabled) {
52
+ return this._runNotify(path, value, prev);
53
+ }
54
+ if (!this.pending.has(path)) {
55
+ this.firstValues.set(path, prev);
56
+ }
57
+ this.pending.set(path, {
58
+ newValue: value,
59
+ oldValue: this.firstValues.get(path)
60
+ });
61
+ if (!this.pendingFlush) {
62
+ this.pendingFlush = true;
63
+ queueMicrotask(() => this.flush());
64
+ }
65
+ return {
66
+ blocked: false,
67
+ value
68
+ };
69
+ }
70
+ _runNotify(path, value, prev) {
37
71
  let blocked = false;
38
72
  let transformed = value;
39
73
  for (const [pattern, interceptorSet] of this.interceptors) {
@@ -67,6 +101,41 @@ class PathNotifier {
67
101
  value: transformed
68
102
  };
69
103
  }
104
+ flush() {
105
+ const toNotify = new Map(this.pending);
106
+ this.pending.clear();
107
+ this.firstValues.clear();
108
+ this.pendingFlush = false;
109
+ for (const [path, {
110
+ newValue,
111
+ oldValue
112
+ }] of toNotify) {
113
+ if (newValue === oldValue) continue;
114
+ const res = this._runNotify(path, newValue, oldValue);
115
+ if (res.blocked) ;
116
+ }
117
+ for (const cb of Array.from(this.flushCallbacks)) {
118
+ try {
119
+ cb();
120
+ } catch {}
121
+ }
122
+ }
123
+ flushSync() {
124
+ while (this.pending.size > 0 || this.pendingFlush) {
125
+ if (this.pendingFlush && this.pending.size === 0) {
126
+ this.pendingFlush = false;
127
+ break;
128
+ }
129
+ this.flush();
130
+ }
131
+ }
132
+ onFlush(callback) {
133
+ this.flushCallbacks.add(callback);
134
+ return () => this.flushCallbacks.delete(callback);
135
+ }
136
+ hasPending() {
137
+ return this.pending.size > 0;
138
+ }
70
139
  matches(pattern, path) {
71
140
  if (pattern === '**') return true;
72
141
  if (pattern === path) return true;
@@ -79,6 +148,9 @@ class PathNotifier {
79
148
  clear() {
80
149
  this.subscribers.clear();
81
150
  this.interceptors.clear();
151
+ this.pending.clear();
152
+ this.firstValues.clear();
153
+ this.pendingFlush = false;
82
154
  }
83
155
  getSubscriberCount() {
84
156
  let count = 0;
@@ -1,6 +1,7 @@
1
1
  import { signal, isSignal } from '@angular/core';
2
2
  import { SIGNAL_TREE_MESSAGES, SIGNAL_TREE_CONSTANTS } from './constants.js';
3
3
  import { SignalMemoryManager } from './memory/memory-manager.js';
4
+ import { getPathNotifier } from './path-notifier.js';
4
5
  import { SecurityValidator } from './security/security-validator.js';
5
6
  import { createLazySignalTree, unwrap } from './utils.js';
6
7
  import { deepEqual } from '../deep-equal.js';
@@ -172,6 +173,9 @@ function create(initialState, config) {
172
173
  const useLazy = shouldUseLazy(initialState, config, estimatedSize);
173
174
  let signalState;
174
175
  let memoryManager;
176
+ try {
177
+ getPathNotifier().setBatchingEnabled(Boolean(config.batchUpdates !== false));
178
+ } catch {}
175
179
  if (useLazy && typeof initialState === 'object') {
176
180
  try {
177
181
  memoryManager = new SignalMemoryManager();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/core",
3
- "version": "6.2.4",
3
+ "version": "6.3.0",
4
4
  "description": "Lightweight, type-safe signal-based state management for Angular. Core package providing hierarchical signal trees, basic entity management, and async actions.",
5
5
  "type": "module",
6
6
  "sideEffects": false,