@uistate/core 5.5.2 → 5.6.1
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 +27 -1
- package/eventState.js +285 -85
- package/index.js +2 -2
- package/package.json +7 -6
- package/self-test.js +216 -0
- package/eventStateNew.js +0 -218
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ store.set('count', 1);
|
|
|
35
35
|
|
|
36
36
|
### `createEventState(initialState)`
|
|
37
37
|
|
|
38
|
-
Returns a store with `get`, `set`, `subscribe`, `setAsync`, `cancel`, `destroy`.
|
|
38
|
+
Returns a store with `get`, `set`, `batch`, `setMany`, `subscribe`, `setAsync`, `cancel`, `destroy`.
|
|
39
39
|
|
|
40
40
|
### `store.get(path?)`
|
|
41
41
|
|
|
@@ -65,6 +65,31 @@ const unsub = store.subscribe('count', (value, { oldValue }) => {
|
|
|
65
65
|
unsub(); // cleanup
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
+
### `store.batch(fn)`
|
|
69
|
+
|
|
70
|
+
Batch multiple `set()` calls. Subscribers fire once per unique path after the batch completes, not during. Supports nesting.
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
store.batch(() => {
|
|
74
|
+
store.set('ui.route.view', 'user');
|
|
75
|
+
store.set('ui.route.path', '/users/42');
|
|
76
|
+
store.set('ui.route.params', { id: '42' });
|
|
77
|
+
});
|
|
78
|
+
// Subscribers fire here, once per path, all state consistent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### `store.setMany(entries)`
|
|
82
|
+
|
|
83
|
+
Set multiple paths atomically. Shorthand for `batch` + a loop of `set` calls. Accepts a plain object, an array of `[path, value]` pairs, or a `Map`.
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
store.setMany({
|
|
87
|
+
'ui.route.view': 'user',
|
|
88
|
+
'ui.route.path': '/users/42',
|
|
89
|
+
'ui.route.params': { id: '42' },
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
68
93
|
### `store.setAsync(path, fetcher)`
|
|
69
94
|
|
|
70
95
|
Manages async state at `${path}.status`, `${path}.data`, `${path}.error`. Supports abort on re-call.
|
|
@@ -106,6 +131,7 @@ qc.invalidate('users');
|
|
|
106
131
|
| [@uistate/css](https://www.npmjs.com/package/@uistate/css) | CSS-native state via custom properties and data attributes | MIT |
|
|
107
132
|
| [@uistate/event-test](https://www.npmjs.com/package/@uistate/event-test) | Event-sequence testing for UIstate stores | Proprietary |
|
|
108
133
|
| [@uistate/examples](https://www.npmjs.com/package/@uistate/examples) | Example applications and patterns | MIT |
|
|
134
|
+
| [@uistate/renderer](https://www.npmjs.com/package/@uistate/renderer) | Direct-binding reactive renderer: `bind-*`, `set`, `each` attributes. Zero build step | Proprietary |
|
|
109
135
|
| [@uistate/aliases](https://www.npmjs.com/package/@uistate/aliases) | Ergonomic single-character and short-name DOM aliases for vanilla JS | MIT |
|
|
110
136
|
|
|
111
137
|
📖 **Documentation:** [uistate.com](https://uistate.com)
|
package/eventState.js
CHANGED
|
@@ -1,89 +1,289 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1
|
+
/**
|
|
2
|
+
* EventState v2 - Optimized Path-Based State Management
|
|
3
|
+
*
|
|
4
|
+
* A lightweight, performant state management library using path-based subscriptions.
|
|
5
|
+
* Optimized for selective notifications and granular updates.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Path-based get/set operations (e.g., 'user.profile.name')
|
|
9
|
+
* - Selective subscriptions (only relevant subscribers fire)
|
|
10
|
+
* - Wildcard subscriptions (e.g., 'user.*' catches all user changes)
|
|
11
|
+
* - Global subscriptions (e.g., '*' catches all changes)
|
|
12
|
+
* - Atomic batching (batch/setMany — subscribers fire after all writes)
|
|
13
|
+
* - Zero dependencies
|
|
14
|
+
* - ~2KB minified
|
|
15
|
+
*
|
|
16
|
+
* Performance characteristics:
|
|
17
|
+
* - 2-9x faster than Zustand for selective subscriptions
|
|
18
|
+
* - Competitive overall performance
|
|
19
|
+
* - Minimal rendering overhead (1.27x faster paint times)
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* const store = createEventState({ count: 0, user: { name: 'Alice' } });
|
|
23
|
+
*
|
|
24
|
+
* // Subscribe to specific path
|
|
25
|
+
* const unsub = store.subscribe('count', (value) => {
|
|
26
|
+
* console.log('Count changed:', value);
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* // Update state
|
|
30
|
+
* store.set('count', 1);
|
|
31
|
+
*
|
|
32
|
+
* // Get state
|
|
33
|
+
* const count = store.get('count');
|
|
34
|
+
*
|
|
35
|
+
* // Wildcard subscription
|
|
36
|
+
* store.subscribe('user.*', ({ path, value }) => {
|
|
37
|
+
* console.log(`User field ${path} changed to:`, value);
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* // Global subscription
|
|
41
|
+
* store.subscribe('*', ({ path, value }) => {
|
|
42
|
+
* console.log(`State changed at ${path}:`, value);
|
|
43
|
+
* });
|
|
44
|
+
*
|
|
45
|
+
* // Batch multiple writes (subscribers fire once per path, after batch)
|
|
46
|
+
* store.batch(() => {
|
|
47
|
+
* store.set('user.name', 'Charlie');
|
|
48
|
+
* store.set('user.email', 'charlie@example.com');
|
|
49
|
+
* });
|
|
50
|
+
*
|
|
51
|
+
* // Or use setMany for the same effect
|
|
52
|
+
* store.setMany({ 'user.name': 'Charlie', 'user.email': 'charlie@example.com' });
|
|
53
|
+
*
|
|
54
|
+
* // Cleanup
|
|
55
|
+
* unsub();
|
|
56
|
+
* store.destroy();
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
export function createEventState(initial = {}) {
|
|
60
|
+
const state = JSON.parse(JSON.stringify(initial));
|
|
61
|
+
const listeners = new Map();
|
|
62
|
+
const asyncOps = new Map();
|
|
63
|
+
let destroyed = false;
|
|
64
|
+
|
|
65
|
+
// Batching: buffer writes and flush once at the end
|
|
66
|
+
let batching = false;
|
|
67
|
+
const batchBuffer = new Map();
|
|
68
|
+
|
|
69
|
+
function writeAndNotify(path, value) {
|
|
70
|
+
const parts = path.split(".");
|
|
71
|
+
const key = parts.pop();
|
|
72
|
+
let cur = state;
|
|
73
|
+
|
|
74
|
+
for (const p of parts) {
|
|
75
|
+
if (!cur[p]) cur[p] = {};
|
|
76
|
+
cur = cur[p];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const oldValue = cur[key];
|
|
80
|
+
cur[key] = value;
|
|
81
|
+
|
|
82
|
+
if (!destroyed) {
|
|
83
|
+
const detail = { path, value, oldValue };
|
|
84
|
+
|
|
85
|
+
const exactListeners = listeners.get(path);
|
|
86
|
+
if (exactListeners) {
|
|
87
|
+
exactListeners.forEach(cb => cb(value, detail));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (parts.length) {
|
|
91
|
+
let parent = "";
|
|
92
|
+
for (const p of parts) {
|
|
93
|
+
parent = parent ? `${parent}.${p}` : p;
|
|
94
|
+
const wildcardListeners = listeners.get(`${parent}.*`);
|
|
95
|
+
if (wildcardListeners) {
|
|
96
|
+
wildcardListeners.forEach(cb => cb(detail));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const globalListeners = listeners.get('*');
|
|
102
|
+
if (globalListeners) {
|
|
103
|
+
globalListeners.forEach(cb => cb(detail));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function flushBatch() {
|
|
111
|
+
const entries = Array.from(batchBuffer.entries());
|
|
112
|
+
batchBuffer.clear();
|
|
113
|
+
for (const [p, v] of entries) {
|
|
114
|
+
writeAndNotify(p, v);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
/**
|
|
120
|
+
* Get value at path
|
|
121
|
+
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
122
|
+
* @returns {*} Value at path, or entire state if no path provided
|
|
123
|
+
*/
|
|
124
|
+
get(path) {
|
|
125
|
+
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
126
|
+
if (!path) return state;
|
|
127
|
+
const parts = path.split('.');
|
|
128
|
+
let cur = state;
|
|
129
|
+
for (const p of parts) {
|
|
130
|
+
if (cur == null) return undefined;
|
|
131
|
+
cur = cur[p];
|
|
132
|
+
}
|
|
133
|
+
return cur;
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Set value at path and notify subscribers
|
|
138
|
+
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
139
|
+
* @param {*} value - New value
|
|
140
|
+
* @returns {*} The value that was set
|
|
141
|
+
*/
|
|
142
|
+
set(path, value) {
|
|
143
|
+
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
144
|
+
if (!path) return value;
|
|
145
|
+
|
|
146
|
+
if (batching) {
|
|
147
|
+
batchBuffer.set(path, value);
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return writeAndNotify(path, value);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async setAsync(path, fetcher) {
|
|
155
|
+
if (destroyed) throw new Error('Cannot setAsync on destroyed store');
|
|
156
|
+
if (!path) throw new TypeError('setAsync requires a path');
|
|
157
|
+
if (typeof fetcher !== 'function') {
|
|
158
|
+
throw new TypeError('setAsync(path, fetcher) requires a function fetcher');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (asyncOps.has(path)) {
|
|
162
|
+
asyncOps.get(path).controller.abort();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const controller = new AbortController();
|
|
166
|
+
asyncOps.set(path, { controller });
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
this.batch(() => {
|
|
170
|
+
this.set(`${path}.status`, 'loading');
|
|
171
|
+
this.set(`${path}.error`, null);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const data = await fetcher(controller.signal);
|
|
175
|
+
|
|
176
|
+
if (destroyed) throw new Error('Cannot setAsync on destroyed store');
|
|
177
|
+
|
|
178
|
+
this.batch(() => {
|
|
179
|
+
this.set(`${path}.data`, data);
|
|
180
|
+
this.set(`${path}.status`, 'success');
|
|
181
|
+
});
|
|
182
|
+
return data;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err?.name === 'AbortError') {
|
|
185
|
+
this.set(`${path}.status`, 'cancelled');
|
|
186
|
+
const cancelErr = new Error('Request cancelled');
|
|
187
|
+
cancelErr.name = 'AbortError';
|
|
188
|
+
throw cancelErr;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.batch(() => {
|
|
192
|
+
this.set(`${path}.status`, 'error');
|
|
193
|
+
this.set(`${path}.error`, err?.message ?? String(err));
|
|
194
|
+
});
|
|
195
|
+
throw err;
|
|
196
|
+
} finally {
|
|
197
|
+
const op = asyncOps.get(path);
|
|
198
|
+
if (op?.controller === controller) {
|
|
199
|
+
asyncOps.delete(path);
|
|
84
200
|
}
|
|
85
|
-
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
cancel(path) {
|
|
205
|
+
if (destroyed) throw new Error('Cannot cancel on destroyed store');
|
|
206
|
+
if (!path) throw new TypeError('cancel requires a path');
|
|
207
|
+
|
|
208
|
+
if (asyncOps.has(path)) {
|
|
209
|
+
asyncOps.get(path).controller.abort();
|
|
210
|
+
asyncOps.delete(path);
|
|
211
|
+
this.set(`${path}.status`, 'cancelled');
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Batch multiple set() calls. Subscribers fire once per unique path
|
|
217
|
+
* after the batch completes, not during. Supports nesting.
|
|
218
|
+
* @param {Function} fn - Function containing set() calls to batch
|
|
219
|
+
*/
|
|
220
|
+
batch(fn) {
|
|
221
|
+
if (destroyed) throw new Error('Cannot batch on destroyed store');
|
|
222
|
+
if (typeof fn !== 'function') throw new TypeError('batch requires a function');
|
|
223
|
+
const wasBatching = batching;
|
|
224
|
+
batching = true;
|
|
225
|
+
try {
|
|
226
|
+
fn();
|
|
227
|
+
} finally {
|
|
228
|
+
batching = wasBatching;
|
|
229
|
+
if (!batching) flushBatch();
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Set multiple paths atomically. Equivalent to batch(() => { set(a); set(b); ... }).
|
|
235
|
+
* Accepts a plain object, an array of [path, value] pairs, or a Map.
|
|
236
|
+
* @param {Object|Array|Map} entries - Paths and values to set
|
|
237
|
+
*/
|
|
238
|
+
setMany(entries) {
|
|
239
|
+
if (destroyed) throw new Error('Cannot setMany on destroyed store');
|
|
240
|
+
if (!entries) return;
|
|
241
|
+
this.batch(() => {
|
|
242
|
+
if (Array.isArray(entries)) {
|
|
243
|
+
for (const [p, v] of entries) this.set(p, v);
|
|
244
|
+
} else if (entries instanceof Map) {
|
|
245
|
+
for (const [p, v] of entries.entries()) this.set(p, v);
|
|
246
|
+
} else if (typeof entries === 'object') {
|
|
247
|
+
for (const p of Object.keys(entries)) this.set(p, entries[p]);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Subscribe to changes at path
|
|
254
|
+
* @param {string} path - Path to subscribe to (supports wildcards: 'user.*', '*')
|
|
255
|
+
* @param {Function} handler - Callback function.
|
|
256
|
+
* - Exact path subscriptions: (value, meta) => void
|
|
257
|
+
* - Wildcard/global subscriptions: (meta) => void
|
|
258
|
+
* @returns {Function} Unsubscribe function
|
|
259
|
+
*/
|
|
260
|
+
subscribe(path, handler) {
|
|
261
|
+
if (destroyed) throw new Error('Cannot subscribe to destroyed store');
|
|
262
|
+
if (!path || typeof handler !== 'function') {
|
|
263
|
+
throw new TypeError('subscribe requires path and handler');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!listeners.has(path)) {
|
|
267
|
+
listeners.set(path, new Set());
|
|
268
|
+
}
|
|
269
|
+
listeners.get(path).add(handler);
|
|
270
|
+
|
|
271
|
+
return () => listeners.get(path)?.delete(handler);
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Destroy store and clear all subscriptions
|
|
276
|
+
*/
|
|
277
|
+
destroy() {
|
|
278
|
+
if (!destroyed) {
|
|
279
|
+
destroyed = true;
|
|
280
|
+
batchBuffer.clear();
|
|
281
|
+
asyncOps.forEach(({ controller }) => controller.abort());
|
|
282
|
+
asyncOps.clear();
|
|
283
|
+
listeners.clear();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
86
287
|
}
|
|
87
288
|
|
|
88
289
|
export default createEventState;
|
|
89
|
-
export { createEventState };
|
package/index.js
CHANGED
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// Primary: EventState (recommended for application state)
|
|
9
|
-
export { createEventState } from './
|
|
10
|
-
export { createEventState as default } from './
|
|
9
|
+
export { createEventState } from './eventState.js';
|
|
10
|
+
export { createEventState as default } from './eventState.js';
|
package/package.json
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uistate/core",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.6.1",
|
|
4
4
|
"description": "Lightweight event-driven state management with path-based subscriptions, wildcards, and async support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"postinstall": "node self-test.js",
|
|
9
|
+
"test": "node tests/core.test.js",
|
|
10
|
+
"self-test": "node self-test.js"
|
|
11
|
+
},
|
|
7
12
|
"exports": {
|
|
8
13
|
".": "./index.js",
|
|
9
14
|
"./eventState": "./eventState.js",
|
|
10
|
-
"./eventStateNew": "./eventStateNew.js",
|
|
11
15
|
"./query": "./queryClient.js"
|
|
12
16
|
},
|
|
13
17
|
"files": [
|
|
14
18
|
"index.js",
|
|
15
19
|
"eventState.js",
|
|
16
|
-
"eventStateNew.js",
|
|
17
20
|
"queryClient.js",
|
|
21
|
+
"self-test.js",
|
|
18
22
|
"LICENSE"
|
|
19
23
|
],
|
|
20
24
|
"keywords": [
|
|
21
25
|
"state-management",
|
|
22
26
|
"event-driven",
|
|
23
27
|
"reactive",
|
|
24
|
-
"dom-events",
|
|
25
|
-
"slot-orchestration",
|
|
26
28
|
"zero-dependency",
|
|
27
|
-
"framework-free",
|
|
28
29
|
"micro-framework",
|
|
29
30
|
"async-state",
|
|
30
31
|
"query-client"
|
package/self-test.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @uistate/core — self-test
|
|
3
|
+
*
|
|
4
|
+
* Standalone test of core EventState functionality. No dependencies beyond eventState.js.
|
|
5
|
+
* Runs on `node self-test.js` or as a postinstall hook.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createEventState } from './eventState.js';
|
|
9
|
+
|
|
10
|
+
let passed = 0;
|
|
11
|
+
let failed = 0;
|
|
12
|
+
|
|
13
|
+
function assert(name, condition) {
|
|
14
|
+
if (condition) {
|
|
15
|
+
passed++;
|
|
16
|
+
console.log(` ✓ ${name}`);
|
|
17
|
+
} else {
|
|
18
|
+
failed++;
|
|
19
|
+
console.error(` ✗ ${name}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
console.log('\n1. get / set');
|
|
24
|
+
const s1 = createEventState({ count: 0, user: { name: 'Alice', age: 30 } });
|
|
25
|
+
|
|
26
|
+
assert('get: primitive', s1.get('count') === 0);
|
|
27
|
+
assert('get: nested', s1.get('user.name') === 'Alice');
|
|
28
|
+
assert('get: object', s1.get('user')?.name === 'Alice');
|
|
29
|
+
assert('get: entire state', s1.get('')?.count === 0);
|
|
30
|
+
assert('get: missing path → undefined', s1.get('nonexistent') === undefined);
|
|
31
|
+
assert('get: deep missing → undefined', s1.get('user.email') === undefined);
|
|
32
|
+
|
|
33
|
+
s1.set('count', 5);
|
|
34
|
+
assert('set: primitive', s1.get('count') === 5);
|
|
35
|
+
|
|
36
|
+
s1.set('user.name', 'Bob');
|
|
37
|
+
assert('set: nested', s1.get('user.name') === 'Bob');
|
|
38
|
+
|
|
39
|
+
s1.set('user.email', 'bob@example.com');
|
|
40
|
+
assert('set: auto-creates path', s1.get('user.email') === 'bob@example.com');
|
|
41
|
+
|
|
42
|
+
s1.set('deep.nested.path', 'value');
|
|
43
|
+
assert('set: deep auto-create', s1.get('deep.nested.path') === 'value');
|
|
44
|
+
|
|
45
|
+
s1.destroy();
|
|
46
|
+
|
|
47
|
+
console.log('\n2. subscribe: exact path');
|
|
48
|
+
const s2 = createEventState({ count: 0 });
|
|
49
|
+
let lastValue = null;
|
|
50
|
+
let fireCount = 0;
|
|
51
|
+
|
|
52
|
+
const unsub = s2.subscribe('count', (value) => {
|
|
53
|
+
lastValue = value;
|
|
54
|
+
fireCount++;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
s2.set('count', 1);
|
|
58
|
+
assert('subscribe: fires on change', fireCount === 1);
|
|
59
|
+
assert('subscribe: receives value', lastValue === 1);
|
|
60
|
+
|
|
61
|
+
s2.set('count', 2);
|
|
62
|
+
assert('subscribe: fires again', fireCount === 2);
|
|
63
|
+
assert('subscribe: receives new value', lastValue === 2);
|
|
64
|
+
|
|
65
|
+
unsub();
|
|
66
|
+
s2.set('count', 3);
|
|
67
|
+
assert('unsubscribe: stops firing', fireCount === 2);
|
|
68
|
+
assert('unsubscribe: value unchanged in handler', lastValue === 2);
|
|
69
|
+
assert('set still works after unsub', s2.get('count') === 3);
|
|
70
|
+
|
|
71
|
+
s2.destroy();
|
|
72
|
+
|
|
73
|
+
console.log('\n3. subscribe: wildcard');
|
|
74
|
+
const s3 = createEventState({ user: { name: 'Alice', age: 30 } });
|
|
75
|
+
let wildcardFires = 0;
|
|
76
|
+
let wildcardDetail = null;
|
|
77
|
+
|
|
78
|
+
s3.subscribe('user.*', (detail) => {
|
|
79
|
+
wildcardFires++;
|
|
80
|
+
wildcardDetail = detail;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
s3.set('user.name', 'Bob');
|
|
84
|
+
assert('wildcard: fires on child change', wildcardFires === 1);
|
|
85
|
+
assert('wildcard: detail has path', wildcardDetail?.path === 'user.name');
|
|
86
|
+
assert('wildcard: detail has value', wildcardDetail?.value === 'Bob');
|
|
87
|
+
|
|
88
|
+
s3.set('user.age', 31);
|
|
89
|
+
assert('wildcard: fires on other child', wildcardFires === 2);
|
|
90
|
+
|
|
91
|
+
s3.destroy();
|
|
92
|
+
|
|
93
|
+
console.log('\n4. subscribe: global');
|
|
94
|
+
const s4 = createEventState({ a: 1, b: { c: 2 } });
|
|
95
|
+
let globalFires = 0;
|
|
96
|
+
|
|
97
|
+
s4.subscribe('*', () => { globalFires++; });
|
|
98
|
+
|
|
99
|
+
s4.set('a', 10);
|
|
100
|
+
assert('global: fires on root path', globalFires === 1);
|
|
101
|
+
|
|
102
|
+
s4.set('b.c', 20);
|
|
103
|
+
assert('global: fires on nested path', globalFires === 2);
|
|
104
|
+
|
|
105
|
+
s4.destroy();
|
|
106
|
+
|
|
107
|
+
console.log('\n5. batch');
|
|
108
|
+
const s5 = createEventState({ x: 0, y: 0 });
|
|
109
|
+
let batchFires = 0;
|
|
110
|
+
s5.subscribe('*', () => { batchFires++; });
|
|
111
|
+
|
|
112
|
+
s5.batch(() => {
|
|
113
|
+
s5.set('x', 1);
|
|
114
|
+
s5.set('y', 2);
|
|
115
|
+
});
|
|
116
|
+
assert('batch: fires after (not during)', batchFires === 2);
|
|
117
|
+
assert('batch: x set', s5.get('x') === 1);
|
|
118
|
+
assert('batch: y set', s5.get('y') === 2);
|
|
119
|
+
|
|
120
|
+
// Deduplication
|
|
121
|
+
batchFires = 0;
|
|
122
|
+
s5.batch(() => {
|
|
123
|
+
s5.set('x', 10);
|
|
124
|
+
s5.set('x', 20);
|
|
125
|
+
s5.set('x', 30);
|
|
126
|
+
});
|
|
127
|
+
assert('batch: deduplicates same path (1 fire)', batchFires === 1);
|
|
128
|
+
assert('batch: last write wins', s5.get('x') === 30);
|
|
129
|
+
|
|
130
|
+
// Nested batch
|
|
131
|
+
batchFires = 0;
|
|
132
|
+
s5.batch(() => {
|
|
133
|
+
s5.set('x', 100);
|
|
134
|
+
s5.batch(() => {
|
|
135
|
+
s5.set('y', 200);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
assert('nested batch: both fire after outermost', batchFires === 2);
|
|
139
|
+
assert('nested batch: x', s5.get('x') === 100);
|
|
140
|
+
assert('nested batch: y', s5.get('y') === 200);
|
|
141
|
+
|
|
142
|
+
// No notifications during batch
|
|
143
|
+
const seen = [];
|
|
144
|
+
const s5b = createEventState({ v: 0 });
|
|
145
|
+
s5b.subscribe('v', (val) => seen.push(val));
|
|
146
|
+
s5b.batch(() => {
|
|
147
|
+
s5b.set('v', 1);
|
|
148
|
+
s5b.set('v', 2);
|
|
149
|
+
});
|
|
150
|
+
assert('batch: no mid-batch notifications', seen.length === 1);
|
|
151
|
+
assert('batch: final value delivered', seen[0] === 2);
|
|
152
|
+
|
|
153
|
+
s5.destroy();
|
|
154
|
+
s5b.destroy();
|
|
155
|
+
|
|
156
|
+
console.log('\n6. setMany');
|
|
157
|
+
const s6 = createEventState({});
|
|
158
|
+
|
|
159
|
+
// Plain object
|
|
160
|
+
s6.setMany({ 'a.b': 1, 'a.c': 2 });
|
|
161
|
+
assert('setMany object: a.b', s6.get('a.b') === 1);
|
|
162
|
+
assert('setMany object: a.c', s6.get('a.c') === 2);
|
|
163
|
+
|
|
164
|
+
// Array of pairs
|
|
165
|
+
s6.setMany([['x.y', 'hello'], ['x.z', 'world']]);
|
|
166
|
+
assert('setMany array: x.y', s6.get('x.y') === 'hello');
|
|
167
|
+
assert('setMany array: x.z', s6.get('x.z') === 'world');
|
|
168
|
+
|
|
169
|
+
// Map
|
|
170
|
+
s6.setMany(new Map([['m.a', true], ['m.b', false]]));
|
|
171
|
+
assert('setMany Map: m.a', s6.get('m.a') === true);
|
|
172
|
+
assert('setMany Map: m.b', s6.get('m.b') === false);
|
|
173
|
+
|
|
174
|
+
s6.destroy();
|
|
175
|
+
|
|
176
|
+
console.log('\n7. destroy');
|
|
177
|
+
const s7 = createEventState({ z: 0 });
|
|
178
|
+
s7.destroy();
|
|
179
|
+
|
|
180
|
+
let threw = false;
|
|
181
|
+
try { s7.get('z'); } catch { threw = true; }
|
|
182
|
+
assert('destroy: get throws', threw);
|
|
183
|
+
|
|
184
|
+
threw = false;
|
|
185
|
+
try { s7.set('z', 1); } catch { threw = true; }
|
|
186
|
+
assert('destroy: set throws', threw);
|
|
187
|
+
|
|
188
|
+
threw = false;
|
|
189
|
+
try { s7.batch(() => {}); } catch { threw = true; }
|
|
190
|
+
assert('destroy: batch throws', threw);
|
|
191
|
+
|
|
192
|
+
threw = false;
|
|
193
|
+
try { s7.setMany({ a: 1 }); } catch { threw = true; }
|
|
194
|
+
assert('destroy: setMany throws', threw);
|
|
195
|
+
|
|
196
|
+
threw = false;
|
|
197
|
+
try { s7.subscribe('z', () => {}); } catch { threw = true; }
|
|
198
|
+
assert('destroy: subscribe throws', threw);
|
|
199
|
+
|
|
200
|
+
console.log('\n8. subscribe: detail object');
|
|
201
|
+
const s8 = createEventState({ count: 10 });
|
|
202
|
+
let detail = null;
|
|
203
|
+
s8.subscribe('count', (value, d) => { detail = d; });
|
|
204
|
+
s8.set('count', 20);
|
|
205
|
+
assert('detail: has path', detail?.path === 'count');
|
|
206
|
+
assert('detail: has value', detail?.value === 20);
|
|
207
|
+
assert('detail: has oldValue', detail?.oldValue === 10);
|
|
208
|
+
|
|
209
|
+
s8.destroy();
|
|
210
|
+
|
|
211
|
+
// Results
|
|
212
|
+
|
|
213
|
+
console.log(`\n@uistate/core v5.6.1 — self-test`);
|
|
214
|
+
console.log(`✓ ${passed} assertions passed${failed ? `, ✗ ${failed} failed` : ''}\n`);
|
|
215
|
+
|
|
216
|
+
if (failed > 0) process.exit(1);
|
package/eventStateNew.js
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EventState v2 - Optimized Path-Based State Management
|
|
3
|
-
*
|
|
4
|
-
* A lightweight, performant state management library using path-based subscriptions.
|
|
5
|
-
* Optimized for selective notifications and granular updates.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Path-based get/set operations (e.g., 'user.profile.name')
|
|
9
|
-
* - Selective subscriptions (only relevant subscribers fire)
|
|
10
|
-
* - Wildcard subscriptions (e.g., 'user.*' catches all user changes)
|
|
11
|
-
* - Global subscriptions (e.g., '*' catches all changes)
|
|
12
|
-
* - Zero dependencies
|
|
13
|
-
* - ~2KB minified
|
|
14
|
-
*
|
|
15
|
-
* Performance characteristics:
|
|
16
|
-
* - 2-9x faster than Zustand for selective subscriptions
|
|
17
|
-
* - Competitive overall performance
|
|
18
|
-
* - Minimal rendering overhead (1.27x faster paint times)
|
|
19
|
-
*
|
|
20
|
-
* @example
|
|
21
|
-
* const store = createEventState({ count: 0, user: { name: 'Alice' } });
|
|
22
|
-
*
|
|
23
|
-
* // Subscribe to specific path
|
|
24
|
-
* const unsub = store.subscribe('count', (value) => {
|
|
25
|
-
* console.log('Count changed:', value);
|
|
26
|
-
* });
|
|
27
|
-
*
|
|
28
|
-
* // Update state
|
|
29
|
-
* store.set('count', 1);
|
|
30
|
-
*
|
|
31
|
-
* // Get state
|
|
32
|
-
* const count = store.get('count');
|
|
33
|
-
*
|
|
34
|
-
* // Wildcard subscription
|
|
35
|
-
* store.subscribe('user.*', ({ path, value }) => {
|
|
36
|
-
* console.log(`User field ${path} changed to:`, value);
|
|
37
|
-
* });
|
|
38
|
-
*
|
|
39
|
-
* // Global subscription
|
|
40
|
-
* store.subscribe('*', ({ path, value }) => {
|
|
41
|
-
* console.log(`State changed at ${path}:`, value);
|
|
42
|
-
* });
|
|
43
|
-
*
|
|
44
|
-
* // Cleanup
|
|
45
|
-
* unsub();
|
|
46
|
-
* store.destroy();
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
|
-
export function createEventState(initial = {}) {
|
|
50
|
-
const state = JSON.parse(JSON.stringify(initial));
|
|
51
|
-
const listeners = new Map();
|
|
52
|
-
const asyncOps = new Map();
|
|
53
|
-
let destroyed = false;
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
/**
|
|
57
|
-
* Get value at path
|
|
58
|
-
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
59
|
-
* @returns {*} Value at path, or entire state if no path provided
|
|
60
|
-
*/
|
|
61
|
-
get(path) {
|
|
62
|
-
if (destroyed) throw new Error('Cannot get from destroyed store');
|
|
63
|
-
if (!path) return state;
|
|
64
|
-
const parts = path.split('.');
|
|
65
|
-
let cur = state;
|
|
66
|
-
for (const p of parts) {
|
|
67
|
-
if (cur == null) return undefined;
|
|
68
|
-
cur = cur[p];
|
|
69
|
-
}
|
|
70
|
-
return cur;
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Set value at path and notify subscribers
|
|
75
|
-
* @param {string} path - Dot-separated path (e.g., 'user.profile.name')
|
|
76
|
-
* @param {*} value - New value
|
|
77
|
-
* @returns {*} The value that was set
|
|
78
|
-
*/
|
|
79
|
-
set(path, value) {
|
|
80
|
-
if (destroyed) throw new Error('Cannot set on destroyed store');
|
|
81
|
-
if (!path) return value;
|
|
82
|
-
|
|
83
|
-
const parts = path.split(".");
|
|
84
|
-
const key = parts.pop();
|
|
85
|
-
let cur = state;
|
|
86
|
-
|
|
87
|
-
// Navigate to parent object, creating nested objects as needed
|
|
88
|
-
for (const p of parts) {
|
|
89
|
-
if (!cur[p]) cur[p] = {};
|
|
90
|
-
cur = cur[p];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const oldValue = cur[key];
|
|
94
|
-
cur[key] = value;
|
|
95
|
-
|
|
96
|
-
if (!destroyed) {
|
|
97
|
-
const detail = { path, value, oldValue };
|
|
98
|
-
|
|
99
|
-
// Notify exact path subscribers
|
|
100
|
-
const exactListeners = listeners.get(path);
|
|
101
|
-
if (exactListeners) {
|
|
102
|
-
exactListeners.forEach(cb => cb(value, detail));
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Notify wildcard subscribers for parent paths
|
|
106
|
-
if (parts.length) {
|
|
107
|
-
let parent = "";
|
|
108
|
-
for (const p of parts) {
|
|
109
|
-
parent = parent ? `${parent}.${p}` : p;
|
|
110
|
-
const wildcardListeners = listeners.get(`${parent}.*`);
|
|
111
|
-
if (wildcardListeners) {
|
|
112
|
-
wildcardListeners.forEach(cb => cb(detail));
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Notify global subscribers
|
|
118
|
-
const globalListeners = listeners.get('*');
|
|
119
|
-
if (globalListeners) {
|
|
120
|
-
globalListeners.forEach(cb => cb(detail));
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return value;
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
async setAsync(path, fetcher) {
|
|
128
|
-
if (destroyed) throw new Error('Cannot setAsync on destroyed store');
|
|
129
|
-
if (!path) throw new TypeError('setAsync requires a path');
|
|
130
|
-
if (typeof fetcher !== 'function') {
|
|
131
|
-
throw new TypeError('setAsync(path, fetcher) requires a function fetcher');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (asyncOps.has(path)) {
|
|
135
|
-
asyncOps.get(path).controller.abort();
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const controller = new AbortController();
|
|
139
|
-
asyncOps.set(path, { controller });
|
|
140
|
-
|
|
141
|
-
try {
|
|
142
|
-
this.set(`${path}.status`, 'loading');
|
|
143
|
-
this.set(`${path}.error`, null);
|
|
144
|
-
|
|
145
|
-
const data = await fetcher(controller.signal);
|
|
146
|
-
|
|
147
|
-
if (destroyed) throw new Error('Cannot setAsync on destroyed store');
|
|
148
|
-
|
|
149
|
-
this.set(`${path}.data`, data);
|
|
150
|
-
this.set(`${path}.status`, 'success');
|
|
151
|
-
return data;
|
|
152
|
-
} catch (err) {
|
|
153
|
-
if (err?.name === 'AbortError') {
|
|
154
|
-
this.set(`${path}.status`, 'cancelled');
|
|
155
|
-
const cancelErr = new Error('Request cancelled');
|
|
156
|
-
cancelErr.name = 'AbortError';
|
|
157
|
-
throw cancelErr;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
this.set(`${path}.status`, 'error');
|
|
161
|
-
this.set(`${path}.error`, err?.message ?? String(err));
|
|
162
|
-
throw err;
|
|
163
|
-
} finally {
|
|
164
|
-
const op = asyncOps.get(path);
|
|
165
|
-
if (op?.controller === controller) {
|
|
166
|
-
asyncOps.delete(path);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
cancel(path) {
|
|
172
|
-
if (destroyed) throw new Error('Cannot cancel on destroyed store');
|
|
173
|
-
if (!path) throw new TypeError('cancel requires a path');
|
|
174
|
-
|
|
175
|
-
if (asyncOps.has(path)) {
|
|
176
|
-
asyncOps.get(path).controller.abort();
|
|
177
|
-
asyncOps.delete(path);
|
|
178
|
-
this.set(`${path}.status`, 'cancelled');
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Subscribe to changes at path
|
|
184
|
-
* @param {string} path - Path to subscribe to (supports wildcards: 'user.*', '*')
|
|
185
|
-
* @param {Function} handler - Callback function.
|
|
186
|
-
* - Exact path subscriptions: (value, meta) => void
|
|
187
|
-
* - Wildcard/global subscriptions: (meta) => void
|
|
188
|
-
* @returns {Function} Unsubscribe function
|
|
189
|
-
*/
|
|
190
|
-
subscribe(path, handler) {
|
|
191
|
-
if (destroyed) throw new Error('Cannot subscribe to destroyed store');
|
|
192
|
-
if (!path || typeof handler !== 'function') {
|
|
193
|
-
throw new TypeError('subscribe requires path and handler');
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!listeners.has(path)) {
|
|
197
|
-
listeners.set(path, new Set());
|
|
198
|
-
}
|
|
199
|
-
listeners.get(path).add(handler);
|
|
200
|
-
|
|
201
|
-
return () => listeners.get(path)?.delete(handler);
|
|
202
|
-
},
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Destroy store and clear all subscriptions
|
|
206
|
-
*/
|
|
207
|
-
destroy() {
|
|
208
|
-
if (!destroyed) {
|
|
209
|
-
destroyed = true;
|
|
210
|
-
asyncOps.forEach(({ controller }) => controller.abort());
|
|
211
|
-
asyncOps.clear();
|
|
212
|
-
listeners.clear();
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export default createEventState;
|