@uistate/core 5.5.1 → 5.6.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
@@ -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.
@@ -99,12 +124,16 @@ qc.invalidate('users');
99
124
 
100
125
  ## Ecosystem
101
126
 
102
- | Package | Description |
103
- |---|---|
104
- | [@uistate/core](https://www.npmjs.com/package/@uistate/core) | This package — event-driven state management |
105
- | [@uistate/css](https://www.npmjs.com/package/@uistate/css) | CSS-native state via custom properties and data attributes |
106
- | [@uistate/event-test](https://www.npmjs.com/package/@uistate/event-test) | Event-sequence testing (proprietary license) |
107
- | [@uistate/examples](https://www.npmjs.com/package/@uistate/examples) | Example applications and patterns |
127
+ | Package | Description | License |
128
+ |---|---|---|
129
+ | [@uistate/core](https://www.npmjs.com/package/@uistate/core) | Path-based state management with wildcard subscriptions and async support | MIT |
130
+ | [@uistate/react](https://www.npmjs.com/package/@uistate/react) | React adapter `usePath`, `useIntent`, `useAsync` hooks and `EventStateProvider` | MIT |
131
+ | [@uistate/css](https://www.npmjs.com/package/@uistate/css) | CSS-native state via custom properties and data attributes | MIT |
132
+ | [@uistate/event-test](https://www.npmjs.com/package/@uistate/event-test) | Event-sequence testing for UIstate stores | Proprietary |
133
+ | [@uistate/examples](https://www.npmjs.com/package/@uistate/examples) | Example applications and patterns | MIT |
134
+ | [@uistate/aliases](https://www.npmjs.com/package/@uistate/aliases) | Ergonomic single-character and short-name DOM aliases for vanilla JS | MIT |
135
+
136
+ 📖 **Documentation:** [uistate.com](https://uistate.com)
108
137
 
109
138
  ## License
110
139
 
@@ -114,6 +143,7 @@ Copyright © 2025 Ajdin Imsirovic
114
143
 
115
144
  ## Links
116
145
 
146
+ - [Documentation](https://uistate.com)
117
147
  - [GitHub](https://github.com/ImsirovicAjdin/uistate)
118
148
  - [npm](https://www.npmjs.com/package/@uistate/core)
119
149
  - [Issues](https://github.com/ImsirovicAjdin/uistate/issues)
package/eventState.js CHANGED
@@ -1,89 +1,289 @@
1
- const createEventState = (initial = {}) => {
2
- const store = JSON.parse(JSON.stringify(initial));
3
-
4
- const bus = new EventTarget();
5
- let destroyed = false;
6
-
7
- return {
8
- get: (path) => {
9
- if (destroyed) throw new Error('Cannot get from destroyed store');
10
- if (!path) return store;
11
-
12
- // Parse dot-paths: 'items.0' ['items', '0']
13
- const parts = path.split('.').flatMap(part => {
14
- const match = part.match(/([^\[]+)\[(\d+)\]/);
15
- return match ? [match[1], match[2]] : part;
16
- });
17
-
18
- return parts.reduce(
19
- (obj, prop) => (obj && obj[prop] !== undefined ? obj[prop] : undefined),
20
- store
21
- );
22
- },
23
- set: (path, value) => {
24
- if (destroyed) throw new Error('Cannot set on destroyed store');
25
- if (!path) return;
26
- const parts = path.split('.');
27
- const last = parts.pop();
28
- let target = store;
29
-
30
- for (const key of parts) {
31
- // create intermediate objects as needed
32
- if (typeof target[key] !== 'object' || target[key] === null) {
33
- target[key] = {};
34
- }
35
- target = target[key];
36
- }
37
-
38
- target[last] = value;
39
-
40
- if (!destroyed) {
41
- // exact path
42
- bus.dispatchEvent(new CustomEvent(path, { detail: value }));
43
-
44
- // parent wildcards: a, a.b -> 'a.*', 'a.b.*'
45
- for (let i = 1; i <= parts.length; i++) {
46
- const parent = parts.slice(0, i).join('.');
47
- bus.dispatchEvent(new CustomEvent(`${parent}.*`, { detail: { path, value } }));
48
- }
49
-
50
- // root wildcard
51
- bus.dispatchEvent(new CustomEvent('*', { detail: { path, value } }));
52
- }
53
-
54
- return value;
55
- },
56
- subscribe(path, handler) {
57
- if (destroyed) throw new Error('store destroyed');
58
- if (!path || typeof handler !== 'function') {
59
- throw new TypeError('subscribe(path, handler) requires a string path and function handler');
60
- }
61
- const onEvent = (evt) => handler(evt.detail, evt);
62
- bus.addEventListener(path, onEvent);
63
-
64
- return function unsubscribe() {
65
- bus.removeEventListener(path, onEvent);
66
- };
67
- },
68
- addStateListener(path, handler) {
69
- return this.subscribe(path, handler);
70
- },
71
- off(unsubscribe) {
72
- if (typeof unsubscribe !== 'function') {
73
- throw new TypeError('off(unsubscribe) requires a function returned by subscribe');
74
- }
75
- return unsubscribe();
76
- },
77
-
78
- destroy() {
79
- if (!destroyed) {
80
- destroyed = true;
81
- // EventTarget has no parentNode - just mark as destroyed
82
- // Future sets/subscribes will be blocked by destroyed flag
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 './eventStateNew.js';
10
- export { createEventState as default } from './eventStateNew.js';
9
+ export { createEventState } from './eventState.js';
10
+ export { createEventState as default } from './eventState.js';
package/package.json CHANGED
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "@uistate/core",
3
- "version": "5.5.1",
3
+ "version": "5.6.0",
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
+ "test": "node tests/test-batch.js && node tests/test-batch-dogfood.js"
9
+ },
7
10
  "exports": {
8
11
  ".": "./index.js",
9
12
  "./eventState": "./eventState.js",
10
- "./eventStateNew": "./eventStateNew.js",
11
13
  "./query": "./queryClient.js"
12
14
  },
13
15
  "files": [
14
16
  "index.js",
15
17
  "eventState.js",
16
- "eventStateNew.js",
17
18
  "queryClient.js",
18
19
  "LICENSE"
19
20
  ],
@@ -21,10 +22,7 @@
21
22
  "state-management",
22
23
  "event-driven",
23
24
  "reactive",
24
- "dom-events",
25
- "slot-orchestration",
26
25
  "zero-dependency",
27
- "framework-free",
28
26
  "micro-framework",
29
27
  "async-state",
30
28
  "query-client"
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;