@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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/lib/signal-tree.js
CHANGED
|
@@ -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.
|
|
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,
|