@signaltree/core 6.2.3 → 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();
@@ -1,120 +1,101 @@
1
1
  import { signal, computed } from '@angular/core';
2
2
 
3
- class EntitySignalImpl {
4
- pathNotifier;
5
- basePath;
6
- storage = new Map();
7
- allSignal;
8
- countSignal;
9
- idsSignal;
10
- mapSignal;
11
- nodeCache = new Map();
12
- selectId;
13
- tapHandlers = [];
14
- interceptHandlers = [];
15
- constructor(config, pathNotifier, basePath) {
16
- this.pathNotifier = pathNotifier;
17
- this.basePath = basePath;
18
- this.selectId = config.selectId ?? (entity => entity['id']);
19
- this.allSignal = signal([]);
20
- this.countSignal = signal(0);
21
- this.idsSignal = signal([]);
22
- this.mapSignal = signal(new Map());
3
+ function createEntitySignal(config, pathNotifier, basePath) {
4
+ const storage = new Map();
5
+ const allSignal = signal([]);
6
+ const countSignal = signal(0);
7
+ const idsSignal = signal([]);
8
+ const mapSignal = signal(new Map());
9
+ const nodeCache = new Map();
10
+ const selectId = config.selectId ?? (entity => entity['id']);
11
+ const tapHandlers = [];
12
+ const interceptHandlers = [];
13
+ function updateSignals() {
14
+ const entities = Array.from(storage.values());
15
+ const ids = Array.from(storage.keys());
16
+ const map = new Map(storage);
17
+ allSignal.set(entities);
18
+ countSignal.set(entities.length);
19
+ idsSignal.set(ids);
20
+ mapSignal.set(map);
23
21
  }
24
- byId(id) {
25
- const entity = this.storage.get(id);
26
- if (!entity) return undefined;
27
- return this.getOrCreateNode(id, entity);
22
+ function createEntityNode(id, entity) {
23
+ const node = () => storage.get(id);
24
+ for (const key of Object.keys(entity)) {
25
+ Object.defineProperty(node, key, {
26
+ get: () => {
27
+ const current = storage.get(id);
28
+ const value = current?.[key];
29
+ return () => value;
30
+ },
31
+ enumerable: true,
32
+ configurable: true
33
+ });
34
+ }
35
+ return node;
28
36
  }
29
- byIdOrFail(id) {
30
- const node = this.byId(id);
37
+ function getOrCreateNode(id, entity) {
38
+ let node = nodeCache.get(id);
31
39
  if (!node) {
32
- throw new Error(`Entity with id ${String(id)} not found`);
40
+ node = createEntityNode(id, entity);
41
+ nodeCache.set(id, node);
33
42
  }
34
43
  return node;
35
44
  }
36
- get all() {
37
- return this.allSignal;
38
- }
39
- get count() {
40
- return this.countSignal;
41
- }
42
- get ids() {
43
- return this.idsSignal;
44
- }
45
- get map() {
46
- return this.mapSignal;
47
- }
48
- has(id) {
49
- return computed(() => this.storage.has(id));
50
- }
51
- get isEmpty() {
52
- return computed(() => this.storage.size === 0);
53
- }
54
- where(predicate) {
55
- return computed(() => {
56
- const result = [];
57
- for (const entity of this.storage.values()) {
58
- if (predicate(entity)) {
59
- result.push(entity);
60
- }
61
- }
62
- return result;
63
- });
64
- }
65
- find(predicate) {
66
- return computed(() => {
67
- for (const entity of this.storage.values()) {
68
- if (predicate(entity)) {
69
- return entity;
70
- }
45
+ const whereCache = new WeakMap();
46
+ const findCache = new WeakMap();
47
+ const api = {
48
+ byId(id) {
49
+ const entity = storage.get(id);
50
+ if (!entity) return undefined;
51
+ return getOrCreateNode(id, entity);
52
+ },
53
+ byIdOrFail(id) {
54
+ const node = api.byId(id);
55
+ if (!node) {
56
+ throw new Error(`Entity with id ${String(id)} not found`);
71
57
  }
72
- return undefined;
73
- });
74
- }
75
- addOne(entity, opts) {
76
- const id = opts?.selectId?.(entity) ?? this.selectId(entity);
77
- if (this.storage.has(id)) {
78
- throw new Error(`Entity with id ${String(id)} already exists`);
79
- }
80
- let transformedEntity = entity;
81
- for (const handler of this.interceptHandlers) {
82
- const ctx = {
83
- block: reason => {
84
- throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
85
- },
86
- transform: value => {
87
- transformedEntity = value;
88
- },
89
- blocked: false,
90
- blockReason: undefined
91
- };
92
- handler.onAdd?.(entity, ctx);
93
- }
94
- this.storage.set(id, transformedEntity);
95
- this.nodeCache.delete(id);
96
- this.updateSignals();
97
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, transformedEntity, undefined);
98
- for (const handler of this.tapHandlers) {
99
- handler.onAdd?.(transformedEntity, id);
100
- }
101
- return id;
102
- }
103
- addMany(entities, opts) {
104
- const idsToAdd = [];
105
- for (const entity of entities) {
106
- const id = opts?.selectId?.(entity) ?? this.selectId(entity);
107
- if (this.storage.has(id)) {
58
+ return node;
59
+ },
60
+ get all() {
61
+ return allSignal;
62
+ },
63
+ get count() {
64
+ return countSignal;
65
+ },
66
+ get ids() {
67
+ return idsSignal;
68
+ },
69
+ get map() {
70
+ return mapSignal;
71
+ },
72
+ has(id) {
73
+ return computed(() => mapSignal().has(id));
74
+ },
75
+ get isEmpty() {
76
+ return computed(() => countSignal() === 0);
77
+ },
78
+ where(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;
84
+ },
85
+ 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;
91
+ },
92
+ addOne(entity, opts) {
93
+ const id = opts?.selectId?.(entity) ?? selectId(entity);
94
+ if (storage.has(id)) {
108
95
  throw new Error(`Entity with id ${String(id)} already exists`);
109
96
  }
110
- idsToAdd.push(id);
111
- }
112
- const addedEntities = [];
113
- for (let i = 0; i < entities.length; i++) {
114
- const entity = entities[i];
115
- const id = idsToAdd[i];
116
97
  let transformedEntity = entity;
117
- for (const handler of this.interceptHandlers) {
98
+ for (const handler of interceptHandlers) {
118
99
  const ctx = {
119
100
  block: reason => {
120
101
  throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
@@ -127,73 +108,74 @@ class EntitySignalImpl {
127
108
  };
128
109
  handler.onAdd?.(entity, ctx);
129
110
  }
130
- this.storage.set(id, transformedEntity);
131
- this.nodeCache.delete(id);
132
- addedEntities.push({
111
+ storage.set(id, transformedEntity);
112
+ nodeCache.delete(id);
113
+ updateSignals();
114
+ pathNotifier.notify(`${basePath}.${String(id)}`, transformedEntity, undefined);
115
+ for (const handler of tapHandlers) {
116
+ handler.onAdd?.(transformedEntity, id);
117
+ }
118
+ return id;
119
+ },
120
+ addMany(entities, opts) {
121
+ const idsToAdd = [];
122
+ for (const entity of entities) {
123
+ const id = opts?.selectId?.(entity) ?? selectId(entity);
124
+ if (storage.has(id)) {
125
+ throw new Error(`Entity with id ${String(id)} already exists`);
126
+ }
127
+ idsToAdd.push(id);
128
+ }
129
+ const addedEntities = [];
130
+ for (let i = 0; i < entities.length; i++) {
131
+ const entity = entities[i];
132
+ const id = idsToAdd[i];
133
+ let transformedEntity = entity;
134
+ for (const handler of interceptHandlers) {
135
+ const ctx = {
136
+ block: reason => {
137
+ throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
138
+ },
139
+ transform: value => {
140
+ transformedEntity = value;
141
+ },
142
+ blocked: false,
143
+ blockReason: undefined
144
+ };
145
+ handler.onAdd?.(entity, ctx);
146
+ }
147
+ storage.set(id, transformedEntity);
148
+ nodeCache.delete(id);
149
+ addedEntities.push({
150
+ id,
151
+ entity: transformedEntity
152
+ });
153
+ }
154
+ updateSignals();
155
+ for (const {
133
156
  id,
134
- entity: transformedEntity
135
- });
136
- }
137
- this.updateSignals();
138
- for (const {
139
- id,
140
- entity
141
- } of addedEntities) {
142
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, entity, undefined);
143
- }
144
- for (const {
145
- id,
146
- entity
147
- } of addedEntities) {
148
- for (const handler of this.tapHandlers) {
149
- handler.onAdd?.(entity, id);
157
+ entity
158
+ } of addedEntities) {
159
+ pathNotifier.notify(`${basePath}.${String(id)}`, entity, undefined);
150
160
  }
151
- }
152
- return idsToAdd;
153
- }
154
- updateOne(id, changes) {
155
- const entity = this.storage.get(id);
156
- if (!entity) {
157
- throw new Error(`Entity with id ${String(id)} not found`);
158
- }
159
- const prev = entity;
160
- let transformedChanges = changes;
161
- for (const handler of this.interceptHandlers) {
162
- const ctx = {
163
- block: reason => {
164
- throw new Error(`Cannot update entity: ${reason || 'blocked by interceptor'}`);
165
- },
166
- transform: value => {
167
- transformedChanges = value;
168
- },
169
- blocked: false,
170
- blockReason: undefined
171
- };
172
- handler.onUpdate?.(id, changes, ctx);
173
- }
174
- const finalUpdated = {
175
- ...entity,
176
- ...transformedChanges
177
- };
178
- this.storage.set(id, finalUpdated);
179
- this.nodeCache.delete(id);
180
- this.updateSignals();
181
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, finalUpdated, prev);
182
- for (const handler of this.tapHandlers) {
183
- handler.onUpdate?.(id, transformedChanges, finalUpdated);
184
- }
185
- }
186
- updateMany(ids, changes) {
187
- if (ids.length === 0) return;
188
- const updatedEntities = [];
189
- for (const id of ids) {
190
- const entity = this.storage.get(id);
161
+ for (const {
162
+ id,
163
+ entity
164
+ } of addedEntities) {
165
+ for (const handler of tapHandlers) {
166
+ handler.onAdd?.(entity, id);
167
+ }
168
+ }
169
+ return idsToAdd;
170
+ },
171
+ updateOne(id, changes) {
172
+ const entity = storage.get(id);
191
173
  if (!entity) {
192
174
  throw new Error(`Entity with id ${String(id)} not found`);
193
175
  }
194
176
  const prev = entity;
195
177
  let transformedChanges = changes;
196
- for (const handler of this.interceptHandlers) {
178
+ for (const handler of interceptHandlers) {
197
179
  const ctx = {
198
180
  block: reason => {
199
181
  throw new Error(`Cannot update entity: ${reason || 'blocked by interceptor'}`);
@@ -210,78 +192,86 @@ class EntitySignalImpl {
210
192
  ...entity,
211
193
  ...transformedChanges
212
194
  };
213
- this.storage.set(id, finalUpdated);
214
- this.nodeCache.delete(id);
215
- updatedEntities.push({
195
+ storage.set(id, finalUpdated);
196
+ nodeCache.delete(id);
197
+ updateSignals();
198
+ pathNotifier.notify(`${basePath}.${String(id)}`, finalUpdated, prev);
199
+ for (const handler of tapHandlers) {
200
+ handler.onUpdate?.(id, transformedChanges, finalUpdated);
201
+ }
202
+ },
203
+ updateMany(ids, changes) {
204
+ if (ids.length === 0) return;
205
+ const updatedEntities = [];
206
+ for (const id of ids) {
207
+ const entity = storage.get(id);
208
+ if (!entity) {
209
+ throw new Error(`Entity with id ${String(id)} not found`);
210
+ }
211
+ const prev = entity;
212
+ let transformedChanges = changes;
213
+ for (const handler of interceptHandlers) {
214
+ const ctx = {
215
+ block: reason => {
216
+ throw new Error(`Cannot update entity: ${reason || 'blocked by interceptor'}`);
217
+ },
218
+ transform: value => {
219
+ transformedChanges = value;
220
+ },
221
+ blocked: false,
222
+ blockReason: undefined
223
+ };
224
+ handler.onUpdate?.(id, changes, ctx);
225
+ }
226
+ const finalUpdated = {
227
+ ...entity,
228
+ ...transformedChanges
229
+ };
230
+ storage.set(id, finalUpdated);
231
+ nodeCache.delete(id);
232
+ updatedEntities.push({
233
+ id,
234
+ prev,
235
+ finalUpdated,
236
+ transformedChanges
237
+ });
238
+ }
239
+ updateSignals();
240
+ for (const {
216
241
  id,
217
242
  prev,
218
- finalUpdated,
219
- transformedChanges
220
- });
221
- }
222
- this.updateSignals();
223
- for (const {
224
- id,
225
- prev,
226
- finalUpdated
227
- } of updatedEntities) {
228
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, finalUpdated, prev);
229
- }
230
- for (const {
231
- id,
232
- transformedChanges,
233
- finalUpdated
234
- } of updatedEntities) {
235
- for (const handler of this.tapHandlers) {
236
- handler.onUpdate?.(id, transformedChanges, finalUpdated);
243
+ finalUpdated
244
+ } of updatedEntities) {
245
+ pathNotifier.notify(`${basePath}.${String(id)}`, finalUpdated, prev);
237
246
  }
238
- }
239
- }
240
- updateWhere(predicate, changes) {
241
- const idsToUpdate = [];
242
- for (const [id, entity] of this.storage) {
243
- if (predicate(entity)) {
244
- idsToUpdate.push(id);
247
+ for (const {
248
+ id,
249
+ transformedChanges,
250
+ finalUpdated
251
+ } of updatedEntities) {
252
+ for (const handler of tapHandlers) {
253
+ handler.onUpdate?.(id, transformedChanges, finalUpdated);
254
+ }
245
255
  }
246
- }
247
- if (idsToUpdate.length > 0) {
248
- this.updateMany(idsToUpdate, changes);
249
- }
250
- return idsToUpdate.length;
251
- }
252
- removeOne(id) {
253
- const entity = this.storage.get(id);
254
- if (!entity) {
255
- throw new Error(`Entity with id ${String(id)} not found`);
256
- }
257
- for (const handler of this.interceptHandlers) {
258
- const ctx = {
259
- block: reason => {
260
- throw new Error(`Cannot remove entity: ${reason || 'blocked by interceptor'}`);
261
- },
262
- transform: () => {},
263
- blocked: false,
264
- blockReason: undefined
265
- };
266
- handler.onRemove?.(id, entity, ctx);
267
- }
268
- this.storage.delete(id);
269
- this.nodeCache.delete(id);
270
- this.updateSignals();
271
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, undefined, entity);
272
- for (const handler of this.tapHandlers) {
273
- handler.onRemove?.(id, entity);
274
- }
275
- }
276
- removeMany(ids) {
277
- if (ids.length === 0) return;
278
- const entitiesToRemove = [];
279
- for (const id of ids) {
280
- const entity = this.storage.get(id);
256
+ },
257
+ updateWhere(predicate, changes) {
258
+ const idsToUpdate = [];
259
+ for (const [id, entity] of storage) {
260
+ if (predicate(entity)) {
261
+ idsToUpdate.push(id);
262
+ }
263
+ }
264
+ if (idsToUpdate.length > 0) {
265
+ api.updateMany(idsToUpdate, changes);
266
+ }
267
+ return idsToUpdate.length;
268
+ },
269
+ removeOne(id) {
270
+ const entity = storage.get(id);
281
271
  if (!entity) {
282
272
  throw new Error(`Entity with id ${String(id)} not found`);
283
273
  }
284
- for (const handler of this.interceptHandlers) {
274
+ for (const handler of interceptHandlers) {
285
275
  const ctx = {
286
276
  block: reason => {
287
277
  throw new Error(`Cannot remove entity: ${reason || 'blocked by interceptor'}`);
@@ -292,267 +282,263 @@ class EntitySignalImpl {
292
282
  };
293
283
  handler.onRemove?.(id, entity, ctx);
294
284
  }
295
- entitiesToRemove.push({
296
- id,
297
- entity
298
- });
299
- }
300
- for (const {
301
- id
302
- } of entitiesToRemove) {
303
- this.storage.delete(id);
304
- this.nodeCache.delete(id);
305
- }
306
- this.updateSignals();
307
- for (const {
308
- id,
309
- entity
310
- } of entitiesToRemove) {
311
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, undefined, entity);
312
- }
313
- for (const {
314
- id,
315
- entity
316
- } of entitiesToRemove) {
317
- for (const handler of this.tapHandlers) {
285
+ storage.delete(id);
286
+ nodeCache.delete(id);
287
+ updateSignals();
288
+ pathNotifier.notify(`${basePath}.${String(id)}`, undefined, entity);
289
+ for (const handler of tapHandlers) {
318
290
  handler.onRemove?.(id, entity);
319
291
  }
320
- }
321
- }
322
- removeWhere(predicate) {
323
- const idsToRemove = [];
324
- for (const [id, entity] of this.storage) {
325
- if (predicate(entity)) {
326
- idsToRemove.push(id);
327
- }
328
- }
329
- if (idsToRemove.length > 0) {
330
- this.removeMany(idsToRemove);
331
- }
332
- return idsToRemove.length;
333
- }
334
- upsertOne(entity, opts) {
335
- const id = opts?.selectId?.(entity) ?? this.selectId(entity);
336
- if (this.storage.has(id)) {
337
- this.updateOne(id, entity);
338
- } else {
339
- this.addOne(entity, opts);
340
- }
341
- return id;
342
- }
343
- upsertMany(entities, opts) {
344
- if (entities.length === 0) return [];
345
- const toAdd = [];
346
- const toUpdate = [];
347
- for (const entity of entities) {
348
- const id = opts?.selectId?.(entity) ?? this.selectId(entity);
349
- if (this.storage.has(id)) {
350
- toUpdate.push({
351
- entity,
292
+ },
293
+ removeMany(ids) {
294
+ if (ids.length === 0) return;
295
+ const entitiesToRemove = [];
296
+ for (const id of ids) {
297
+ const entity = storage.get(id);
298
+ if (!entity) {
299
+ throw new Error(`Entity with id ${String(id)} not found`);
300
+ }
301
+ for (const handler of interceptHandlers) {
302
+ const ctx = {
303
+ block: reason => {
304
+ throw new Error(`Cannot remove entity: ${reason || 'blocked by interceptor'}`);
305
+ },
306
+ transform: () => {},
307
+ blocked: false,
308
+ blockReason: undefined
309
+ };
310
+ handler.onRemove?.(id, entity, ctx);
311
+ }
312
+ entitiesToRemove.push({
352
313
  id,
353
- prev: this.storage.get(id)
314
+ entity
354
315
  });
316
+ }
317
+ for (const {
318
+ id
319
+ } of entitiesToRemove) {
320
+ storage.delete(id);
321
+ nodeCache.delete(id);
322
+ }
323
+ updateSignals();
324
+ for (const {
325
+ id,
326
+ entity
327
+ } of entitiesToRemove) {
328
+ pathNotifier.notify(`${basePath}.${String(id)}`, undefined, entity);
329
+ }
330
+ for (const {
331
+ id,
332
+ entity
333
+ } of entitiesToRemove) {
334
+ for (const handler of tapHandlers) {
335
+ handler.onRemove?.(id, entity);
336
+ }
337
+ }
338
+ },
339
+ removeWhere(predicate) {
340
+ const idsToRemove = [];
341
+ for (const [id, entity] of storage) {
342
+ if (predicate(entity)) {
343
+ idsToRemove.push(id);
344
+ }
345
+ }
346
+ if (idsToRemove.length > 0) {
347
+ api.removeMany(idsToRemove);
348
+ }
349
+ return idsToRemove.length;
350
+ },
351
+ upsertOne(entity, opts) {
352
+ const id = opts?.selectId?.(entity) ?? selectId(entity);
353
+ if (storage.has(id)) {
354
+ api.updateOne(id, entity);
355
355
  } else {
356
- toAdd.push({
357
- entity,
358
- id
356
+ api.addOne(entity, opts);
357
+ }
358
+ return id;
359
+ },
360
+ upsertMany(entities, opts) {
361
+ if (entities.length === 0) return [];
362
+ const toAdd = [];
363
+ const toUpdate = [];
364
+ for (const entity of entities) {
365
+ const id = opts?.selectId?.(entity) ?? selectId(entity);
366
+ const existing = storage.get(id);
367
+ if (existing !== undefined) {
368
+ toUpdate.push({
369
+ entity,
370
+ id,
371
+ prev: existing
372
+ });
373
+ } else {
374
+ toAdd.push({
375
+ entity,
376
+ id
377
+ });
378
+ }
379
+ }
380
+ const addedEntities = [];
381
+ for (const {
382
+ entity,
383
+ id
384
+ } of toAdd) {
385
+ let transformedEntity = entity;
386
+ for (const handler of interceptHandlers) {
387
+ const ctx = {
388
+ block: reason => {
389
+ throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
390
+ },
391
+ transform: value => {
392
+ transformedEntity = value;
393
+ },
394
+ blocked: false,
395
+ blockReason: undefined
396
+ };
397
+ handler.onAdd?.(entity, ctx);
398
+ }
399
+ storage.set(id, transformedEntity);
400
+ nodeCache.delete(id);
401
+ addedEntities.push({
402
+ id,
403
+ entity: transformedEntity
359
404
  });
360
405
  }
361
- }
362
- const addedEntities = [];
363
- for (const {
364
- entity,
365
- id
366
- } of toAdd) {
367
- let transformedEntity = entity;
368
- for (const handler of this.interceptHandlers) {
369
- const ctx = {
370
- block: reason => {
371
- throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
372
- },
373
- transform: value => {
374
- transformedEntity = value;
375
- },
376
- blocked: false,
377
- blockReason: undefined
406
+ const updatedEntities = [];
407
+ for (const {
408
+ entity,
409
+ id,
410
+ prev
411
+ } of toUpdate) {
412
+ let transformedChanges = entity;
413
+ for (const handler of interceptHandlers) {
414
+ const ctx = {
415
+ block: reason => {
416
+ throw new Error(`Cannot update entity: ${reason || 'blocked by interceptor'}`);
417
+ },
418
+ transform: value => {
419
+ transformedChanges = value;
420
+ },
421
+ blocked: false,
422
+ blockReason: undefined
423
+ };
424
+ handler.onUpdate?.(id, entity, ctx);
425
+ }
426
+ const finalUpdated = {
427
+ ...prev,
428
+ ...transformedChanges
378
429
  };
379
- handler.onAdd?.(entity, ctx);
430
+ storage.set(id, finalUpdated);
431
+ nodeCache.delete(id);
432
+ updatedEntities.push({
433
+ id,
434
+ prev,
435
+ finalUpdated,
436
+ transformedChanges
437
+ });
380
438
  }
381
- this.storage.set(id, transformedEntity);
382
- this.nodeCache.delete(id);
383
- addedEntities.push({
439
+ updateSignals();
440
+ for (const {
384
441
  id,
385
- entity: transformedEntity
386
- });
387
- }
388
- const updatedEntities = [];
389
- for (const {
390
- entity,
391
- id,
392
- prev
393
- } of toUpdate) {
394
- let transformedChanges = entity;
395
- for (const handler of this.interceptHandlers) {
396
- const ctx = {
397
- block: reason => {
398
- throw new Error(`Cannot update entity: ${reason || 'blocked by interceptor'}`);
399
- },
400
- transform: value => {
401
- transformedChanges = value;
402
- },
403
- blocked: false,
404
- blockReason: undefined
405
- };
406
- handler.onUpdate?.(id, entity, ctx);
442
+ entity
443
+ } of addedEntities) {
444
+ pathNotifier.notify(`${basePath}.${String(id)}`, entity, undefined);
407
445
  }
408
- const finalUpdated = {
409
- ...prev,
410
- ...transformedChanges
411
- };
412
- this.storage.set(id, finalUpdated);
413
- this.nodeCache.delete(id);
414
- updatedEntities.push({
446
+ for (const {
415
447
  id,
416
448
  prev,
417
- finalUpdated,
418
- transformedChanges
419
- });
420
- }
421
- this.updateSignals();
422
- for (const {
423
- id,
424
- entity
425
- } of addedEntities) {
426
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, entity, undefined);
427
- }
428
- for (const {
429
- id,
430
- prev,
431
- finalUpdated
432
- } of updatedEntities) {
433
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, finalUpdated, prev);
434
- }
435
- for (const {
436
- id,
437
- entity
438
- } of addedEntities) {
439
- for (const handler of this.tapHandlers) {
440
- handler.onAdd?.(entity, id);
449
+ finalUpdated
450
+ } of updatedEntities) {
451
+ pathNotifier.notify(`${basePath}.${String(id)}`, finalUpdated, prev);
441
452
  }
442
- }
443
- for (const {
444
- id,
445
- transformedChanges,
446
- finalUpdated
447
- } of updatedEntities) {
448
- for (const handler of this.tapHandlers) {
449
- handler.onUpdate?.(id, transformedChanges, finalUpdated);
453
+ for (const {
454
+ id,
455
+ entity
456
+ } of addedEntities) {
457
+ for (const handler of tapHandlers) {
458
+ handler.onAdd?.(entity, id);
459
+ }
450
460
  }
451
- }
452
- return [...toAdd.map(a => a.id), ...toUpdate.map(u => u.id)];
453
- }
454
- clear() {
455
- this.storage.clear();
456
- this.nodeCache.clear();
457
- this.updateSignals();
458
- }
459
- removeAll() {
460
- this.clear();
461
- }
462
- setAll(entities, opts) {
463
- this.storage.clear();
464
- this.nodeCache.clear();
465
- const addedIds = [];
466
- for (const entity of entities) {
467
- const id = opts?.selectId?.(entity) ?? this.selectId(entity);
468
- let transformedEntity = entity;
469
- for (const handler of this.interceptHandlers) {
470
- const ctx = {
471
- block: reason => {
472
- throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
473
- },
474
- transform: value => {
475
- transformedEntity = value;
476
- },
477
- blocked: false,
478
- blockReason: undefined
479
- };
480
- handler.onAdd?.(entity, ctx);
461
+ for (const {
462
+ id,
463
+ transformedChanges,
464
+ finalUpdated
465
+ } of updatedEntities) {
466
+ for (const handler of tapHandlers) {
467
+ handler.onUpdate?.(id, transformedChanges, finalUpdated);
468
+ }
481
469
  }
482
- this.storage.set(id, transformedEntity);
483
- addedIds.push(id);
484
- }
485
- this.updateSignals();
486
- for (let i = 0; i < addedIds.length; i++) {
487
- const id = addedIds[i];
488
- const entity = this.storage.get(id);
489
- this.pathNotifier.notify(`${this.basePath}.${String(id)}`, entity, undefined);
490
- }
491
- for (let i = 0; i < addedIds.length; i++) {
492
- const id = addedIds[i];
493
- const entity = this.storage.get(id);
494
- for (const handler of this.tapHandlers) {
495
- handler.onAdd?.(entity, id);
470
+ return [...toAdd.map(a => a.id), ...toUpdate.map(u => u.id)];
471
+ },
472
+ clear() {
473
+ storage.clear();
474
+ nodeCache.clear();
475
+ updateSignals();
476
+ },
477
+ removeAll() {
478
+ api.clear();
479
+ },
480
+ setAll(entities, opts) {
481
+ storage.clear();
482
+ nodeCache.clear();
483
+ const addedIds = [];
484
+ for (const entity of entities) {
485
+ const id = opts?.selectId?.(entity) ?? selectId(entity);
486
+ let transformedEntity = entity;
487
+ for (const handler of interceptHandlers) {
488
+ const ctx = {
489
+ block: reason => {
490
+ throw new Error(`Cannot add entity: ${reason || 'blocked by interceptor'}`);
491
+ },
492
+ transform: value => {
493
+ transformedEntity = value;
494
+ },
495
+ blocked: false,
496
+ blockReason: undefined
497
+ };
498
+ handler.onAdd?.(entity, ctx);
499
+ }
500
+ storage.set(id, transformedEntity);
501
+ addedIds.push(id);
496
502
  }
503
+ updateSignals();
504
+ for (let i = 0; i < addedIds.length; i++) {
505
+ const id = addedIds[i];
506
+ const entity = storage.get(id);
507
+ pathNotifier.notify(`${basePath}.${String(id)}`, entity, undefined);
508
+ }
509
+ for (let i = 0; i < addedIds.length; i++) {
510
+ const id = addedIds[i];
511
+ const entity = storage.get(id);
512
+ if (entity) {
513
+ for (const handler of tapHandlers) {
514
+ handler.onAdd?.(entity, id);
515
+ }
516
+ }
517
+ }
518
+ },
519
+ tap(handlers) {
520
+ tapHandlers.push(handlers);
521
+ return () => {
522
+ const idx = tapHandlers.indexOf(handlers);
523
+ if (idx > -1) tapHandlers.splice(idx, 1);
524
+ };
525
+ },
526
+ intercept(handlers) {
527
+ interceptHandlers.push(handlers);
528
+ return () => {
529
+ const idx = interceptHandlers.indexOf(handlers);
530
+ if (idx > -1) interceptHandlers.splice(idx, 1);
531
+ };
497
532
  }
498
- }
499
- tap(handlers) {
500
- this.tapHandlers.push(handlers);
501
- return () => {
502
- const idx = this.tapHandlers.indexOf(handlers);
503
- if (idx > -1) this.tapHandlers.splice(idx, 1);
504
- };
505
- }
506
- intercept(handlers) {
507
- this.interceptHandlers.push(handlers);
508
- return () => {
509
- const idx = this.interceptHandlers.indexOf(handlers);
510
- if (idx > -1) this.interceptHandlers.splice(idx, 1);
511
- };
512
- }
513
- updateSignals() {
514
- const entities = Array.from(this.storage.values());
515
- const ids = Array.from(this.storage.keys());
516
- const map = new Map(this.storage);
517
- this.allSignal.set(entities);
518
- this.countSignal.set(entities.length);
519
- this.idsSignal.set(ids);
520
- this.mapSignal.set(map);
521
- }
522
- getOrCreateNode(id, entity) {
523
- let node = this.nodeCache.get(id);
524
- if (!node) {
525
- node = this.createEntityNode(id, entity);
526
- this.nodeCache.set(id, node);
527
- }
528
- return node;
529
- }
530
- createEntityNode(id, entity) {
531
- const node = () => this.storage.get(id);
532
- for (const key of Object.keys(entity)) {
533
- Object.defineProperty(node, key, {
534
- get: () => {
535
- const current = this.storage.get(id);
536
- const value = current?.[key];
537
- return () => value;
538
- },
539
- enumerable: true,
540
- configurable: true
541
- });
542
- }
543
- return node;
544
- }
545
- }
546
- function createEntitySignal(config, pathNotifier, basePath) {
547
- const impl = new EntitySignalImpl(config, pathNotifier, basePath);
548
- return new Proxy(impl, {
533
+ };
534
+ return new Proxy(api, {
549
535
  get: (target, prop) => {
550
536
  if (typeof prop === 'string' && !isNaN(Number(prop))) {
551
- return target.byId(Number(prop));
537
+ return api.byId(Number(prop));
552
538
  }
553
539
  return target[prop];
554
540
  }
555
541
  });
556
542
  }
557
543
 
558
- export { EntitySignalImpl, createEntitySignal };
544
+ export { createEntitySignal };
@@ -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.3",
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,