@coherent.js/state 1.1.2 → 2.0.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/dist/index.js CHANGED
@@ -14,11 +14,107 @@ var globalErrorHandler = {
14
14
  console.error("State Error:", error.message, context);
15
15
  }
16
16
  };
17
- var Observable = class _Observable {
17
+ var MAX_FLUSH_ITERATIONS = 100;
18
+ var activeComputed = null;
19
+ var batchDepth = 0;
20
+ var flushing = false;
21
+ var pendingObservables = /* @__PURE__ */ new Map();
22
+ var pendingComputeds = /* @__PURE__ */ new Set();
23
+ var globalVersion = 0;
24
+ function reportError(source, error, type, context = {}) {
25
+ const onError = source?._options?.onError;
26
+ if (typeof onError === "function") {
27
+ try {
28
+ onError(error, { type, ...context });
29
+ return;
30
+ } catch (handlerError) {
31
+ error = handlerError;
32
+ }
33
+ }
34
+ globalErrorHandler.handle(error, { type, context });
35
+ }
36
+ function runObserver(source, observer, newValue, oldValue) {
37
+ try {
38
+ observer.callback(newValue, oldValue, observer.unwatch);
39
+ } catch (error) {
40
+ reportError(source, error, "watcher-error", { newValue, oldValue });
41
+ }
42
+ }
43
+ function flush() {
44
+ flushing = true;
45
+ let iterations = 0;
46
+ try {
47
+ while (pendingObservables.size > 0 || pendingComputeds.size > 0) {
48
+ if (++iterations > MAX_FLUSH_ITERATIONS) {
49
+ const culprits = [...pendingObservables.keys(), ...pendingComputeds];
50
+ pendingObservables.clear();
51
+ pendingComputeds.clear();
52
+ reportError(
53
+ culprits[0],
54
+ new StateError(
55
+ `Watchers kept changing state after ${MAX_FLUSH_ITERATIONS} rounds; a watcher probably writes a new value to a state it (indirectly) watches.`,
56
+ { type: "update-depth" }
57
+ ),
58
+ "update-depth"
59
+ );
60
+ return;
61
+ }
62
+ const observables = [...pendingObservables];
63
+ pendingObservables.clear();
64
+ for (const [source, oldValue] of observables) {
65
+ const newValue = source._value;
66
+ if (!source._changed(oldValue, newValue)) continue;
67
+ for (const observer of [...source._observers]) {
68
+ if (source._observers.has(observer)) {
69
+ runObserver(source, observer, newValue, oldValue);
70
+ }
71
+ }
72
+ }
73
+ const computeds = [...pendingComputeds];
74
+ pendingComputeds.clear();
75
+ for (const source of computeds) {
76
+ if (source._observers.size === 0) continue;
77
+ try {
78
+ source._refresh();
79
+ } catch (error) {
80
+ reportError(source, error, "computed-error");
81
+ continue;
82
+ }
83
+ const newValue = source._value;
84
+ const oldValue = source._lastNotified;
85
+ if (Object.is(newValue, oldValue)) continue;
86
+ source._lastNotified = newValue;
87
+ for (const observer of [...source._observers]) {
88
+ if (source._observers.has(observer)) {
89
+ runObserver(source, observer, newValue, oldValue);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ } finally {
95
+ flushing = false;
96
+ }
97
+ }
98
+ function scheduleFlush() {
99
+ if (batchDepth === 0 && !flushing) {
100
+ flush();
101
+ }
102
+ }
103
+ function batch(fn) {
104
+ batchDepth++;
105
+ try {
106
+ return fn();
107
+ } finally {
108
+ batchDepth--;
109
+ scheduleFlush();
110
+ }
111
+ }
112
+ var Observable = class {
18
113
  constructor(value, options = {}) {
19
114
  this._value = value;
115
+ this._version = 0;
20
116
  this._observers = /* @__PURE__ */ new Set();
21
- this._computedDependents = /* @__PURE__ */ new Set();
117
+ this._subscribers = /* @__PURE__ */ new Set();
22
118
  this._options = {
23
119
  deep: options.deep !== false,
24
120
  immediate: options.immediate !== false,
@@ -26,107 +122,281 @@ var Observable = class _Observable {
26
122
  };
27
123
  }
28
124
  get value() {
29
- if (_Observable._currentComputed) {
30
- this._computedDependents.add(_Observable._currentComputed);
31
- }
125
+ activeComputed?._track(this);
32
126
  return this._value;
33
127
  }
34
128
  set value(newValue) {
35
- if (this._value === newValue && !this._options.deep) {
129
+ this._write(newValue);
130
+ }
131
+ /** Read without registering a dependency. */
132
+ peek() {
133
+ return this._value;
134
+ }
135
+ /**
136
+ * Whether a write from `oldValue` to `newValue` is a change. Identical
137
+ * primitives never are; with `deep` (the default) re-assigning the same
138
+ * object is, since it may have been mutated in place.
139
+ * @private
140
+ */
141
+ _changed(oldValue, newValue) {
142
+ if (!Object.is(oldValue, newValue)) return true;
143
+ return this._options.deep && newValue !== null && typeof newValue === "object";
144
+ }
145
+ /** @private */
146
+ _write(newValue) {
147
+ const oldValue = this._value;
148
+ if (!this._changed(oldValue, newValue)) {
36
149
  return;
37
150
  }
38
- const oldValue = this._value;
39
151
  this._value = newValue;
40
- this._observers.forEach((observer) => {
41
- try {
42
- observer(newValue, oldValue);
43
- } catch (_error) {
44
- globalErrorHandler.handle(_error, {
45
- type: "watcher-_error",
46
- context: { newValue, oldValue }
47
- });
48
- }
49
- });
50
- this._computedDependents.forEach((computed2) => {
51
- computed2._invalidate();
52
- });
152
+ this._version++;
153
+ globalVersion++;
154
+ for (const subscriber of [...this._subscribers]) {
155
+ subscriber._markDirty();
156
+ }
157
+ if (this._observers.size > 0 && !pendingObservables.has(this)) {
158
+ pendingObservables.set(this, oldValue);
159
+ }
160
+ scheduleFlush();
161
+ }
162
+ /** @private */
163
+ _addSubscriber(computed2) {
164
+ this._subscribers.add(computed2);
165
+ }
166
+ /** @private */
167
+ _removeSubscriber(computed2) {
168
+ this._subscribers.delete(computed2);
169
+ }
170
+ /** @private */
171
+ _addObserver(observer) {
172
+ this._observers.add(observer);
173
+ }
174
+ /** @private */
175
+ _removeObserver(observer) {
176
+ this._observers.delete(observer);
53
177
  }
54
178
  watch(callback, options = {}) {
55
179
  if (typeof callback !== "function") {
56
180
  throw new StateError("Watch callback must be a function");
57
181
  }
58
- const observer = (newValue, oldValue) => {
59
- callback(newValue, oldValue, () => this.unwatch(observer));
60
- };
61
- this._observers.add(observer);
182
+ const observer = { callback, unwatch: null };
183
+ observer.unwatch = () => this._removeObserver(observer);
184
+ this._addObserver(observer);
62
185
  if (options.immediate !== false) {
63
- observer(this._value, void 0);
186
+ runObserver(this, observer, this._value, void 0);
64
187
  }
65
- return () => this.unwatch(observer);
188
+ return observer.unwatch;
66
189
  }
67
- unwatch(observer) {
68
- this._observers.delete(observer);
190
+ /**
191
+ * Remove a watcher, by the callback passed to watch()
192
+ * @param {Function} callback
193
+ */
194
+ unwatch(callback) {
195
+ for (const observer of [...this._observers]) {
196
+ if (observer.callback === callback || observer.unwatch === callback) {
197
+ this._removeObserver(observer);
198
+ }
199
+ }
69
200
  }
201
+ /** Remove every watcher. */
70
202
  unwatchAll() {
71
- this._observers.clear();
72
- this._computedDependents.clear();
203
+ for (const observer of [...this._observers]) {
204
+ this._removeObserver(observer);
205
+ }
73
206
  }
74
207
  };
75
- var Computed = class extends Observable {
208
+ var Computed = class _Computed extends Observable {
76
209
  constructor(getter, options = {}) {
77
- super(void 0, options);
78
- this._getter = getter;
79
- this._cached = false;
80
- this._dirty = true;
81
210
  if (typeof getter !== "function") {
82
211
  throw new StateError("Computed getter must be a function");
83
212
  }
213
+ super(void 0, options);
214
+ this._getter = getter;
215
+ this._deps = /* @__PURE__ */ new Map();
216
+ this._dirty = true;
217
+ this._computing = false;
218
+ this._globalVersionSeen = -1;
219
+ this._lastNotified = void 0;
84
220
  }
85
221
  get value() {
86
- if (this._dirty || !this._cached) {
87
- this._compute();
88
- }
222
+ this._refresh();
223
+ activeComputed?._track(this);
89
224
  return this._value;
90
225
  }
91
- set value(newValue) {
226
+ set value(_newValue) {
92
227
  throw new StateError("Cannot set value on computed property");
93
228
  }
94
- _compute() {
95
- const prevComputed = Observable._currentComputed;
96
- Observable._currentComputed = this;
97
- try {
98
- const newValue = this._getter();
99
- if (newValue !== this._value) {
100
- const oldValue = this._value;
101
- this._value = newValue;
102
- this._observers.forEach((observer) => {
103
- observer(newValue, oldValue);
104
- });
105
- }
106
- this._cached = true;
107
- this._dirty = false;
108
- } catch (_error) {
109
- globalErrorHandler.handle(_error, {
110
- type: "computed-_error",
111
- context: { getter: this._getter.toString() }
229
+ peek() {
230
+ this._refresh();
231
+ return this._value;
232
+ }
233
+ /** Watched, or a dependency of a live computed. @private */
234
+ get _live() {
235
+ return this._observers.size > 0 || this._subscribers.size > 0;
236
+ }
237
+ /** @private */
238
+ _track(source) {
239
+ if (!this._deps.has(source)) {
240
+ this._deps.set(source, source._version);
241
+ }
242
+ }
243
+ /** Bring the cached value up to date. @private */
244
+ _refresh() {
245
+ if (this._computing) {
246
+ throw new StateError("Circular dependency between computed properties", {
247
+ type: "computed-cycle",
248
+ context: { getter: this._getter.name || "anonymous" }
112
249
  });
250
+ }
251
+ if (!this._dirty) {
252
+ if (this._live || this._globalVersionSeen === globalVersion) return;
253
+ if (!this._dependenciesChanged()) {
254
+ this._globalVersionSeen = globalVersion;
255
+ return;
256
+ }
257
+ }
258
+ this._recompute();
259
+ }
260
+ /** @private */
261
+ _dependenciesChanged() {
262
+ for (const [source, version] of this._deps) {
263
+ if (source instanceof _Computed) {
264
+ source._refresh();
265
+ }
266
+ if (source._version !== version) return true;
267
+ }
268
+ return false;
269
+ }
270
+ /** @private */
271
+ _recompute() {
272
+ const previousDeps = this._deps;
273
+ const previousActive = activeComputed;
274
+ this._deps = /* @__PURE__ */ new Map();
275
+ this._computing = true;
276
+ activeComputed = this;
277
+ let newValue;
278
+ try {
279
+ newValue = this._getter();
280
+ } catch (error) {
281
+ this._deps = previousDeps;
282
+ this._dirty = true;
283
+ throw error;
113
284
  } finally {
114
- Observable._currentComputed = prevComputed;
285
+ this._computing = false;
286
+ activeComputed = previousActive;
287
+ }
288
+ if (this._live) {
289
+ for (const source of previousDeps.keys()) {
290
+ if (!this._deps.has(source)) source._removeSubscriber(this);
291
+ }
292
+ for (const source of this._deps.keys()) {
293
+ if (!previousDeps.has(source)) source._addSubscriber(this);
294
+ }
295
+ }
296
+ this._dirty = false;
297
+ this._globalVersionSeen = globalVersion;
298
+ if (!Object.is(newValue, this._value)) {
299
+ this._value = newValue;
300
+ this._version++;
115
301
  }
116
302
  }
117
- _invalidate() {
303
+ /** A dependency changed. @private */
304
+ _markDirty() {
305
+ if (this._dirty) return;
118
306
  this._dirty = true;
119
- this._computedDependents.forEach((computed2) => {
120
- computed2._invalidate();
121
- });
307
+ if (this._observers.size > 0) {
308
+ pendingComputeds.add(this);
309
+ }
310
+ for (const subscriber of [...this._subscribers]) {
311
+ subscriber._markDirty();
312
+ }
313
+ }
314
+ /** Subscribe to dependencies. @private */
315
+ _goLive() {
316
+ this._refresh();
317
+ for (const source of this._deps.keys()) {
318
+ source._addSubscriber(this);
319
+ }
320
+ }
321
+ /** Unsubscribe from dependencies. @private */
322
+ _goLazy() {
323
+ for (const source of this._deps.keys()) {
324
+ source._removeSubscriber(this);
325
+ }
326
+ }
327
+ /** @private */
328
+ _addSubscriber(computed2) {
329
+ const wasLive = this._live;
330
+ this._subscribers.add(computed2);
331
+ if (!wasLive) this._goLive();
332
+ }
333
+ /** @private */
334
+ _removeSubscriber(computed2) {
335
+ this._subscribers.delete(computed2);
336
+ if (!this._live) this._goLazy();
337
+ }
338
+ /** @private */
339
+ _addObserver(observer) {
340
+ const hadObservers = this._observers.size > 0;
341
+ const wasLive = this._live;
342
+ this._observers.add(observer);
343
+ try {
344
+ if (wasLive) {
345
+ this._refresh();
346
+ } else {
347
+ this._goLive();
348
+ }
349
+ } catch (error) {
350
+ this._observers.delete(observer);
351
+ throw error;
352
+ }
353
+ if (!hadObservers) {
354
+ this._lastNotified = this._value;
355
+ }
356
+ }
357
+ /** @private */
358
+ _removeObserver(observer) {
359
+ if (!this._observers.delete(observer)) return;
360
+ if (!this._live) this._goLazy();
122
361
  }
123
362
  };
124
- Observable._currentComputed = null;
363
+ var ABSENT = /* @__PURE__ */ Symbol("absent");
364
+ function isPath(key) {
365
+ return typeof key === "string" && key.includes(".");
366
+ }
367
+ function readPath(value, segments) {
368
+ let current = value;
369
+ for (const segment of segments) {
370
+ if (current === null || current === void 0) return void 0;
371
+ current = current[segment];
372
+ }
373
+ return current;
374
+ }
375
+ function writePath(target, segments, value) {
376
+ const [head, ...rest] = segments;
377
+ const base = target !== null && typeof target === "object" ? Array.isArray(target) ? [...target] : { ...target } : {};
378
+ const next = rest.length === 0 ? value : writePath(base[head], rest, value);
379
+ Object.defineProperty(base, head, { value: next, enumerable: true, writable: true, configurable: true });
380
+ return base;
381
+ }
382
+ function deletePath(target, segments) {
383
+ if (target === null || typeof target !== "object") return target;
384
+ const [head, ...rest] = segments;
385
+ if (!Object.prototype.hasOwnProperty.call(target, head)) return target;
386
+ const base = Array.isArray(target) ? [...target] : { ...target };
387
+ if (rest.length === 0) {
388
+ delete base[head];
389
+ } else {
390
+ base[head] = deletePath(base[head], rest);
391
+ }
392
+ return base;
393
+ }
125
394
  var ReactiveState = class {
126
395
  constructor(initialState = {}, options = {}) {
127
396
  this._state = /* @__PURE__ */ new Map();
128
397
  this._computed = /* @__PURE__ */ new Map();
129
398
  this._watchers = /* @__PURE__ */ new Map();
399
+ this._expressionWatchers = /* @__PURE__ */ new Set();
130
400
  this._middleware = [];
131
401
  this._history = [];
132
402
  this._options = {
@@ -140,57 +410,115 @@ var ReactiveState = class {
140
410
  this.set(key, value);
141
411
  });
142
412
  }
413
+ /** Observable for a key, created as an absent placeholder if needed. @private */
414
+ _observable(key) {
415
+ let observable2 = this._state.get(key);
416
+ if (!observable2) {
417
+ observable2 = new Observable(ABSENT, this._options);
418
+ this._state.set(key, observable2);
419
+ }
420
+ return observable2;
421
+ }
422
+ /** Whether a key is stored under its full name. @private */
423
+ _hasOwnKey(key) {
424
+ const observable2 = this._state.get(key);
425
+ return Boolean(observable2) && observable2._value !== ABSENT;
426
+ }
427
+ /** [rootKey, pathSegments] for a dot path, or null for a plain key. @private */
428
+ _splitPath(key) {
429
+ if (!isPath(key)) return null;
430
+ const [root, ...segments] = key.split(".");
431
+ return [root, segments];
432
+ }
143
433
  /**
144
434
  * Get reactive state value
145
435
  */
146
436
  get(key) {
147
- const observable2 = this._state.get(key);
148
- return observable2 ? observable2.value : void 0;
437
+ const path = this._splitPath(key);
438
+ if (path) {
439
+ return readPath(this.get(path[0]), path[1]);
440
+ }
441
+ const observable2 = activeComputed ? this._observable(key) : this._state.get(key);
442
+ if (!observable2) return void 0;
443
+ const value = observable2.value;
444
+ return value === ABSENT ? void 0 : value;
149
445
  }
150
446
  /**
151
447
  * Set reactive state value
152
448
  */
153
449
  set(key, value, options = {}) {
154
450
  const config = { ...this._options, ...options };
451
+ const oldValue = this.get(key);
155
452
  if (config.enableMiddleware) {
156
- const middlewareResult = this._runMiddleware("set", { key, value, oldValue: this.get(key) });
453
+ const middlewareResult = this._runMiddleware("set", { key, value, oldValue });
157
454
  if (middlewareResult.cancelled) {
158
455
  return false;
159
456
  }
160
457
  value = middlewareResult.value !== void 0 ? middlewareResult.value : value;
161
458
  }
162
- let observable2 = this._state.get(key);
163
- if (!observable2) {
164
- observable2 = new Observable(value, config);
165
- this._state.set(key, observable2);
166
- } else {
459
+ const path = this._splitPath(key);
460
+ if (path) {
461
+ const [root, segments] = path;
167
462
  if (config.enableHistory) {
168
- this._addToHistory("set", key, observable2.value, value);
463
+ this._addToHistory("set", key, oldValue, value);
169
464
  }
170
- observable2.value = value;
465
+ this._writeKey(root, writePath(this.get(root), segments, value));
466
+ return true;
467
+ }
468
+ if (config.enableHistory && this._hasOwnKey(key)) {
469
+ this._addToHistory("set", key, oldValue, value);
171
470
  }
471
+ this._writeKey(key, value);
172
472
  return true;
173
473
  }
474
+ /** @private */
475
+ _writeKey(key, value) {
476
+ this._observable(key)._write(value);
477
+ }
174
478
  /**
175
479
  * Check if state has a key
176
480
  */
177
481
  has(key) {
178
- return this._state.has(key);
482
+ const path = this._splitPath(key);
483
+ if (path) {
484
+ const parent = readPath(this.get(path[0]), path[1].slice(0, -1));
485
+ return parent !== null && typeof parent === "object" && Object.prototype.hasOwnProperty.call(parent, path[1][path[1].length - 1]);
486
+ }
487
+ return this._hasOwnKey(key);
179
488
  }
180
489
  /**
181
- * Delete state key
490
+ * Delete state key. Its watchers are removed; computed properties that
491
+ * read it update.
182
492
  */
183
493
  delete(key) {
184
- const observable2 = this._state.get(key);
185
- if (observable2) {
494
+ const path = this._splitPath(key);
495
+ if (path) {
496
+ if (!this.has(key)) return false;
186
497
  if (this._options.enableHistory) {
187
- this._addToHistory("delete", key, observable2.value, void 0);
498
+ this._addToHistory("delete", key, this.get(key), void 0);
188
499
  }
189
- observable2.unwatchAll();
190
- this._state.delete(key);
500
+ this._writeKey(path[0], deletePath(this.get(path[0]), path[1]));
191
501
  return true;
192
502
  }
193
- return false;
503
+ if (!this._hasOwnKey(key)) {
504
+ return false;
505
+ }
506
+ const observable2 = this._state.get(key);
507
+ if (this._options.enableHistory) {
508
+ this._addToHistory("delete", key, observable2._value, void 0);
509
+ }
510
+ observable2.unwatchAll();
511
+ this._releaseKeyWatchers(key);
512
+ observable2._write(ABSENT);
513
+ return true;
514
+ }
515
+ /** @private */
516
+ _releaseKeyWatchers(key) {
517
+ const unwatchers = this._watchers.get(key);
518
+ if (unwatchers) {
519
+ for (const unwatch of unwatchers) unwatch();
520
+ this._watchers.delete(key);
521
+ }
194
522
  }
195
523
  /**
196
524
  * Clear all state
@@ -199,10 +527,13 @@ var ReactiveState = class {
199
527
  if (this._options.enableHistory) {
200
528
  this._addToHistory("clear", null, this.toObject(), {});
201
529
  }
202
- for (const observable2 of this._state.values()) {
203
- observable2.unwatchAll();
204
- }
205
- this._state.clear();
530
+ batch(() => {
531
+ for (const [key, observable2] of this._state) {
532
+ observable2.unwatchAll();
533
+ this._releaseKeyWatchers(key);
534
+ observable2._write(ABSENT);
535
+ }
536
+ });
206
537
  this._computed.clear();
207
538
  this._watchers.clear();
208
539
  }
@@ -225,40 +556,62 @@ var ReactiveState = class {
225
556
  return computed2 ? computed2.value : void 0;
226
557
  }
227
558
  /**
228
- * Watch state changes
559
+ * Watch state changes: a key, a dot path into a key, or a getter
560
+ * expression (re-evaluated whenever what it reads changes).
229
561
  */
230
562
  watch(key, callback, options = {}) {
231
563
  if (typeof key === "function") {
232
564
  return this._watchComputed(key, callback, options);
233
565
  }
234
- const observable2 = this._state.get(key);
235
- if (!observable2) {
566
+ const path = this._splitPath(key);
567
+ if (path) {
568
+ if (!this._hasOwnKey(path[0])) {
569
+ throw new StateError(`Cannot watch undefined state key: ${path[0]}`);
570
+ }
571
+ const computed2 = new Computed(() => this.get(key), { ...this._options, ...options });
572
+ return this._track(key, computed2.watch(callback, options));
573
+ }
574
+ if (!this._hasOwnKey(key)) {
236
575
  throw new StateError(`Cannot watch undefined state key: ${key}`);
237
576
  }
238
- const unwatch = observable2.watch(callback, options);
577
+ return this._track(key, this._state.get(key).watch(callback, options));
578
+ }
579
+ /** Remember an unwatch function for cleanup. @private */
580
+ _track(key, unwatch) {
239
581
  if (!this._watchers.has(key)) {
240
582
  this._watchers.set(key, /* @__PURE__ */ new Set());
241
583
  }
242
- this._watchers.get(key).add(unwatch);
243
- return unwatch;
584
+ const unwatchers = this._watchers.get(key);
585
+ const release = () => {
586
+ unwatch();
587
+ unwatchers.delete(release);
588
+ };
589
+ unwatchers.add(release);
590
+ return release;
244
591
  }
245
592
  /**
246
593
  * Watch computed expression
247
594
  */
248
595
  _watchComputed(expression, callback, options = {}) {
249
- const computed2 = new Computed(expression, options);
596
+ const computed2 = new Computed(expression, { ...this._options, ...options });
250
597
  const unwatch = computed2.watch(callback, options);
251
- return unwatch;
598
+ const release = () => {
599
+ unwatch();
600
+ this._expressionWatchers.delete(release);
601
+ };
602
+ this._expressionWatchers.add(release);
603
+ return release;
252
604
  }
253
605
  /**
254
- * Batch state updates
606
+ * Batch state updates: watchers run once, after every update, with the
607
+ * final values.
255
608
  */
256
609
  batch(updates) {
257
610
  if (typeof updates === "function") {
258
611
  const oldEnableHistory = this._options.enableHistory;
259
612
  this._options.enableHistory = false;
260
613
  try {
261
- const result = updates(this);
614
+ const result = batch(() => updates(this));
262
615
  if (oldEnableHistory) {
263
616
  this._addToHistory("batch", null, null, this.toObject());
264
617
  }
@@ -318,11 +671,8 @@ var ReactiveState = class {
318
671
  break;
319
672
  }
320
673
  }
321
- } catch (_error) {
322
- globalErrorHandler.handle(_error, {
323
- type: "middleware-_error",
324
- context: { action, middleware: middleware.toString() }
325
- });
674
+ } catch (error) {
675
+ reportError(this, error, "middleware-error", { action });
326
676
  }
327
677
  }
328
678
  return result;
@@ -386,28 +736,28 @@ var ReactiveState = class {
386
736
  * Convert state to plain object
387
737
  */
388
738
  toObject() {
389
- const result = {};
390
- for (const [key, observable2] of this._state.entries()) {
391
- result[key] = observable2.value;
392
- }
393
- return result;
739
+ return Object.fromEntries(
740
+ [...this._state].filter(([, observable2]) => observable2._value !== ABSENT).map(([key, observable2]) => [key, observable2._value])
741
+ );
394
742
  }
395
743
  /**
396
744
  * Convert computed properties to object
397
745
  */
398
746
  getComputedValues() {
399
- const result = {};
400
- for (const [key, computed2] of this._computed.entries()) {
401
- result[key] = computed2.value;
402
- }
403
- return result;
747
+ return Object.fromEntries(
748
+ [...this._computed].map(([key, computed2]) => [key, computed2.value])
749
+ );
404
750
  }
405
751
  /**
406
752
  * Get state statistics
407
753
  */
408
754
  getStats() {
755
+ let stateKeys = 0;
756
+ for (const observable2 of this._state.values()) {
757
+ if (observable2._value !== ABSENT) stateKeys++;
758
+ }
409
759
  return {
410
- stateKeys: this._state.size,
760
+ stateKeys,
411
761
  computedKeys: this._computed.size,
412
762
  watcherKeys: this._watchers.size,
413
763
  historyLength: this._history.length,
@@ -418,6 +768,12 @@ var ReactiveState = class {
418
768
  * Cleanup and destroy
419
769
  */
420
770
  destroy() {
771
+ for (const key of [...this._watchers.keys()]) {
772
+ this._releaseKeyWatchers(key);
773
+ }
774
+ for (const release of [...this._expressionWatchers]) {
775
+ release();
776
+ }
421
777
  for (const observable2 of this._state.values()) {
422
778
  observable2.unwatchAll();
423
779
  }
@@ -545,147 +901,199 @@ var globalStateManager = {
545
901
  return createState();
546
902
  }
547
903
  };
548
- var contextStacks = /* @__PURE__ */ new Map();
549
- function provideContext(key, value) {
550
- if (!contextStacks.has(key)) {
551
- contextStacks.set(key, []);
904
+ var EMPTY_SCOPE = /* @__PURE__ */ new Map();
905
+ function createAsyncStorage() {
906
+ try {
907
+ const asyncHooks = globalThis.process?.getBuiltinModule?.("node:async_hooks");
908
+ const AsyncLocalStorage = asyncHooks?.AsyncLocalStorage;
909
+ return typeof AsyncLocalStorage === "function" ? new AsyncLocalStorage() : null;
910
+ } catch {
911
+ return null;
552
912
  }
553
- const stack = contextStacks.get(key);
554
- const previousValue = globalState.get(key);
555
- stack.push(previousValue);
556
- globalState.set(key, value);
913
+ }
914
+ var asyncStorage = createAsyncStorage();
915
+ var syncHolder = { scope: EMPTY_SCOPE, owned: true };
916
+ function currentHolder() {
917
+ return asyncStorage ? asyncStorage.getStore() : syncHolder;
918
+ }
919
+ function currentScope() {
920
+ return currentHolder()?.scope ?? EMPTY_SCOPE;
921
+ }
922
+ function setScope(scope, holder = currentHolder()) {
923
+ if (!holder?.owned) {
924
+ throw new Error(
925
+ "Context can only be provided inside runWithContext() on the server: outside it the value would leak into other requests. Wrap each request or render: runWithContext(() => ...)."
926
+ );
927
+ }
928
+ holder.scope = scope;
929
+ }
930
+ function runInScope(scope, fn, args) {
931
+ if (asyncStorage) {
932
+ return asyncStorage.run({ scope, owned: true }, fn, ...args);
933
+ }
934
+ const previous = syncHolder.scope;
935
+ syncHolder.scope = scope;
936
+ try {
937
+ return fn(...args);
938
+ } finally {
939
+ syncHolder.scope = previous;
940
+ }
941
+ }
942
+ function withValue(scope, key, value) {
943
+ const next = new Map(scope);
944
+ next.set(key, { value, previous: scope.get(key) });
945
+ return next;
946
+ }
947
+ function runWithContext(fn, values) {
948
+ if (typeof fn !== "function") {
949
+ throw new TypeError(`runWithContext() requires a function, received: ${typeof fn}`);
950
+ }
951
+ let scope = EMPTY_SCOPE;
952
+ if (values && typeof values === "object") {
953
+ for (const [key, value] of Object.entries(values)) {
954
+ scope = withValue(scope, key, value);
955
+ }
956
+ }
957
+ return runInScope(scope, fn, []);
958
+ }
959
+ function provideContext(key, value) {
960
+ setScope(withValue(currentScope(), key, value));
557
961
  }
558
962
  function createContextProvider(key, value, children) {
559
- return (renderFunction) => {
560
- try {
561
- provideContext(key, value);
562
- if (renderFunction && typeof renderFunction === "function") {
563
- return renderFunction(children);
564
- } else {
565
- return children;
566
- }
567
- } finally {
568
- restoreContext(key);
963
+ function contextProvider(...args) {
964
+ const renderFunction = args[0];
965
+ const scope = withValue(currentScope(), key, value);
966
+ if (typeof renderFunction === "function") {
967
+ return runInScope(scope, renderFunction, [children]);
569
968
  }
969
+ return runInScope(scope, resolveComponents, [children]);
970
+ }
971
+ return contextProvider;
972
+ }
973
+ var TAG_NAME = /^[a-zA-Z][a-zA-Z0-9-]*$/;
974
+ function resolveComponents(node, depth = 0) {
975
+ if (depth > 1e3) return node;
976
+ if (typeof node === "function") {
977
+ return resolveComponents(node(), depth + 1);
978
+ }
979
+ if (Array.isArray(node)) {
980
+ let changed2 = false;
981
+ const resolved2 = node.map((child) => {
982
+ const next = resolveComponents(child, depth + 1);
983
+ if (next !== child) changed2 = true;
984
+ return next;
985
+ });
986
+ return changed2 ? resolved2 : node;
987
+ }
988
+ if (!node || typeof node !== "object") return node;
989
+ if (node.__isLazy === true && typeof node.evaluate === "function") {
990
+ return resolveComponents(node.evaluate(), depth + 1);
991
+ }
992
+ const tags = Object.keys(node);
993
+ if (tags.length === 0 || !tags.every((tag) => TAG_NAME.test(tag))) return node;
994
+ let changed = false;
995
+ const resolved = {};
996
+ for (const tag of tags) {
997
+ let content = node[tag];
998
+ if (typeof content === "function") {
999
+ content = resolveComponents(content(), depth + 1);
1000
+ }
1001
+ if (content && typeof content === "object" && !Array.isArray(content) && !isTrusted(content)) {
1002
+ content = resolveProps(content, depth);
1003
+ }
1004
+ if (content !== node[tag]) changed = true;
1005
+ resolved[tag] = content;
1006
+ }
1007
+ return changed ? resolved : node;
1008
+ }
1009
+ function isTrusted(value) {
1010
+ return value[/* @__PURE__ */ Symbol.for("coherent.js.trustedContent")] === true;
1011
+ }
1012
+ function resolveProps(props, depth) {
1013
+ let next = props;
1014
+ const set = (key, value) => {
1015
+ if (next === props) next = { ...props };
1016
+ next[key] = value;
570
1017
  };
1018
+ for (const key of Object.keys(props)) {
1019
+ const value = props[key];
1020
+ if (key === "children") {
1021
+ if (value !== void 0 && value !== null) {
1022
+ const children = resolveComponents(value, depth + 1);
1023
+ if (children !== value) set(key, children);
1024
+ }
1025
+ } else if (typeof value === "function" && key !== "key") {
1026
+ if (key === "text" || key === "html") {
1027
+ set(key, value());
1028
+ } else if (!key.startsWith("on")) {
1029
+ try {
1030
+ set(key, value());
1031
+ } catch {
1032
+ }
1033
+ }
1034
+ }
1035
+ }
1036
+ return next;
571
1037
  }
572
1038
  function restoreContext(key) {
573
- if (!contextStacks.has(key)) return;
574
- const stack = contextStacks.get(key);
575
- const previousValue = stack.pop();
576
- if (stack.length === 0) {
577
- if (previousValue === void 0) {
578
- globalState.delete(key);
579
- } else {
580
- globalState.set(key, previousValue);
581
- }
582
- contextStacks.delete(key);
1039
+ const scope = currentScope();
1040
+ const entry = scope.get(key);
1041
+ if (!entry) return;
1042
+ const next = new Map(scope);
1043
+ if (entry.previous) {
1044
+ next.set(key, entry.previous);
583
1045
  } else {
584
- globalState.set(key, previousValue);
1046
+ next.delete(key);
585
1047
  }
1048
+ setScope(next);
586
1049
  }
587
1050
  function clearAllContexts() {
588
- for (const [key, stack] of contextStacks) {
589
- const beforeFirstProvide = stack[0];
590
- if (beforeFirstProvide === void 0) {
591
- globalState.delete(key);
592
- } else {
593
- globalState.set(key, beforeFirstProvide);
594
- }
1051
+ if (currentScope().size > 0) {
1052
+ setScope(EMPTY_SCOPE);
595
1053
  }
596
- contextStacks.clear();
597
1054
  }
598
1055
  function useContext(key) {
599
- return globalState.get(key);
1056
+ const entry = currentScope().get(key);
1057
+ return entry ? entry.value : globalState.get(key);
600
1058
  }
601
1059
 
602
1060
  // src/state-persistence.js
603
- var LocalStorageAdapter = class {
604
- constructor() {
605
- this.available = typeof localStorage !== "undefined";
1061
+ var WebStorageAdapter = class {
1062
+ constructor(storageName) {
1063
+ this.storageName = storageName;
1064
+ this.available = typeof globalThis[storageName] !== "undefined" && globalThis[storageName] !== null;
1065
+ }
1066
+ get storage() {
1067
+ return globalThis[this.storageName];
606
1068
  }
607
1069
  async get(key) {
608
1070
  if (!this.available) return null;
609
- try {
610
- return localStorage.getItem(key);
611
- } catch (error) {
612
- console.error("LocalStorage get error:", error);
613
- return null;
614
- }
1071
+ return this.storage.getItem(key);
615
1072
  }
616
1073
  async set(key, value) {
617
1074
  if (!this.available) return false;
618
- try {
619
- localStorage.setItem(key, value);
620
- return true;
621
- } catch (error) {
622
- console.error("LocalStorage set error:", error);
623
- return false;
624
- }
1075
+ this.storage.setItem(key, value);
1076
+ return true;
625
1077
  }
626
1078
  async remove(key) {
627
1079
  if (!this.available) return false;
628
- try {
629
- localStorage.removeItem(key);
630
- return true;
631
- } catch (error) {
632
- console.error("LocalStorage remove error:", error);
633
- return false;
634
- }
1080
+ this.storage.removeItem(key);
1081
+ return true;
635
1082
  }
636
1083
  async clear() {
637
1084
  if (!this.available) return false;
638
- try {
639
- localStorage.clear();
640
- return true;
641
- } catch (error) {
642
- console.error("LocalStorage clear error:", error);
643
- return false;
644
- }
1085
+ this.storage.clear();
1086
+ return true;
645
1087
  }
646
1088
  };
647
- var SessionStorageAdapter = class {
1089
+ var LocalStorageAdapter = class extends WebStorageAdapter {
648
1090
  constructor() {
649
- this.available = typeof sessionStorage !== "undefined";
650
- }
651
- async get(key) {
652
- if (!this.available) return null;
653
- try {
654
- return sessionStorage.getItem(key);
655
- } catch (error) {
656
- console.error("SessionStorage get error:", error);
657
- return null;
658
- }
659
- }
660
- async set(key, value) {
661
- if (!this.available) return false;
662
- try {
663
- sessionStorage.setItem(key, value);
664
- return true;
665
- } catch (error) {
666
- console.error("SessionStorage set error:", error);
667
- return false;
668
- }
669
- }
670
- async remove(key) {
671
- if (!this.available) return false;
672
- try {
673
- sessionStorage.removeItem(key);
674
- return true;
675
- } catch (error) {
676
- console.error("SessionStorage remove error:", error);
677
- return false;
678
- }
1091
+ super("localStorage");
679
1092
  }
680
- async clear() {
681
- if (!this.available) return false;
682
- try {
683
- sessionStorage.clear();
684
- return true;
685
- } catch (error) {
686
- console.error("SessionStorage clear error:", error);
687
- return false;
688
- }
1093
+ };
1094
+ var SessionStorageAdapter = class extends WebStorageAdapter {
1095
+ constructor() {
1096
+ super("sessionStorage");
689
1097
  }
690
1098
  };
691
1099
  var IndexedDBAdapter = class {
@@ -694,19 +1102,21 @@ var IndexedDBAdapter = class {
694
1102
  this.storeName = storeName;
695
1103
  this.available = typeof indexedDB !== "undefined";
696
1104
  this.db = null;
1105
+ this.opening = null;
697
1106
  }
698
- async init() {
699
- if (!this.available) return false;
700
- if (this.db) return true;
1107
+ /**
1108
+ * Open the database, creating the store in an upgrade. Without `version`,
1109
+ * opens the current version (creating version 1 for a new database).
1110
+ */
1111
+ open(version) {
701
1112
  return new Promise((resolve, reject) => {
702
- const request = indexedDB.open(this.dbName, 1);
1113
+ const request = version === void 0 ? indexedDB.open(this.dbName) : indexedDB.open(this.dbName, version);
703
1114
  request.onerror = () => {
704
1115
  console.error("IndexedDB open error:", request.error);
705
1116
  reject(request.error);
706
1117
  };
707
1118
  request.onsuccess = () => {
708
- this.db = request.result;
709
- resolve(true);
1119
+ resolve(request.result);
710
1120
  };
711
1121
  request.onupgradeneeded = (event) => {
712
1122
  const db = event.target.result;
@@ -716,6 +1126,27 @@ var IndexedDBAdapter = class {
716
1126
  };
717
1127
  });
718
1128
  }
1129
+ async init() {
1130
+ if (!this.available) return false;
1131
+ if (this.db) return true;
1132
+ this.opening ??= (async () => {
1133
+ let db = await this.open();
1134
+ if (!db.objectStoreNames.contains(this.storeName)) {
1135
+ const version = db.version + 1;
1136
+ db.close();
1137
+ db = await this.open(version);
1138
+ }
1139
+ db.onversionchange = () => {
1140
+ db.close();
1141
+ if (this.db === db) this.db = null;
1142
+ };
1143
+ this.db = db;
1144
+ return true;
1145
+ })().finally(() => {
1146
+ this.opening = null;
1147
+ });
1148
+ return this.opening;
1149
+ }
719
1150
  async get(key) {
720
1151
  if (!this.available) return null;
721
1152
  await this.init();
@@ -801,47 +1232,85 @@ var MemoryAdapter = class {
801
1232
  return true;
802
1233
  }
803
1234
  };
804
- var SimpleEncryption = class {
805
- constructor(key) {
806
- this.key = key || "default-key";
1235
+ var ServerAdapter = class {
1236
+ constructor() {
1237
+ this.available = false;
1238
+ }
1239
+ async get() {
1240
+ return null;
807
1241
  }
808
- encrypt(text) {
809
- let result = "";
810
- for (let i = 0; i < text.length; i++) {
811
- result += String.fromCharCode(
812
- text.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length)
1242
+ async set() {
1243
+ return false;
1244
+ }
1245
+ async remove() {
1246
+ return false;
1247
+ }
1248
+ async clear() {
1249
+ return false;
1250
+ }
1251
+ };
1252
+ function toBase64(bytes) {
1253
+ let binary = "";
1254
+ for (let i = 0; i < bytes.length; i += 32768) {
1255
+ binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
1256
+ }
1257
+ return btoa(binary);
1258
+ }
1259
+ function fromBase64(encoded) {
1260
+ const binary = atob(encoded);
1261
+ const bytes = new Uint8Array(binary.length);
1262
+ for (let i = 0; i < binary.length; i++) {
1263
+ bytes[i] = binary.charCodeAt(i);
1264
+ }
1265
+ return bytes;
1266
+ }
1267
+ var XorObfuscation = class {
1268
+ constructor(key) {
1269
+ if (typeof key !== "string" || key.length === 0) {
1270
+ throw new TypeError(
1271
+ "createPersistentState: `encrypt: true` requires a non-empty `encryptionKey`. It is XOR obfuscation, not encryption; there is no default key."
813
1272
  );
814
1273
  }
815
- return btoa(result);
1274
+ this.keyBytes = new globalThis.TextEncoder().encode(key);
816
1275
  }
817
- decrypt(encrypted) {
818
- const text = atob(encrypted);
819
- let result = "";
820
- for (let i = 0; i < text.length; i++) {
821
- result += String.fromCharCode(
822
- text.charCodeAt(i) ^ this.key.charCodeAt(i % this.key.length)
823
- );
1276
+ xor(bytes) {
1277
+ for (let i = 0; i < bytes.length; i++) {
1278
+ bytes[i] ^= this.keyBytes[i % this.keyBytes.length];
824
1279
  }
825
- return result;
1280
+ return bytes;
1281
+ }
1282
+ encode(text) {
1283
+ return toBase64(this.xor(new globalThis.TextEncoder().encode(text)));
1284
+ }
1285
+ decode(encoded) {
1286
+ return new globalThis.TextDecoder().decode(this.xor(fromBase64(encoded)));
826
1287
  }
827
1288
  };
828
- function createStorageAdapter(type) {
1289
+ function createStorageAdapter(type, options = {}) {
829
1290
  switch (type) {
830
1291
  case "localStorage":
831
1292
  return new LocalStorageAdapter();
832
1293
  case "sessionStorage":
833
1294
  return new SessionStorageAdapter();
834
1295
  case "indexedDB":
835
- return new IndexedDBAdapter();
1296
+ return new IndexedDBAdapter(options.dbName ?? void 0, options.storeName ?? void 0);
836
1297
  case "memory":
837
1298
  return new MemoryAdapter();
838
1299
  default:
839
1300
  return new LocalStorageAdapter();
840
1301
  }
841
1302
  }
1303
+ function createInstanceId() {
1304
+ const cryptoApi = globalThis.crypto;
1305
+ if (cryptoApi && typeof cryptoApi.randomUUID === "function") {
1306
+ return cryptoApi.randomUUID();
1307
+ }
1308
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1309
+ }
842
1310
  function createPersistentState(initialState = {}, options = {}) {
843
1311
  const opts = {
844
1312
  storage: "localStorage",
1313
+ adapter: null,
845
1314
  key: "coherent-state",
846
1315
  debounce: true,
847
1316
  debounceDelay: 300,
@@ -861,11 +1330,30 @@ function createPersistentState(initialState = {}, options = {}) {
861
1330
  crossTab: false,
862
1331
  ...options
863
1332
  };
864
- const adapter = createStorageAdapter(opts.storage);
865
- const encryption = opts.encrypt ? new SimpleEncryption(opts.encryptionKey) : null;
1333
+ const onServer = typeof window === "undefined";
1334
+ const obfuscation = opts.encrypt ? new XorObfuscation(opts.encryptionKey) : null;
1335
+ let adapter;
1336
+ if (opts.adapter) {
1337
+ adapter = opts.adapter;
1338
+ } else if (onServer && opts.storage !== "memory") {
1339
+ adapter = new ServerAdapter();
1340
+ } else {
1341
+ adapter = createStorageAdapter(opts.storage, opts);
1342
+ }
1343
+ const instanceId = createInstanceId();
866
1344
  let state = { ...initialState };
867
1345
  let saveTimeout = null;
1346
+ let destroyed = false;
868
1347
  const listeners = /* @__PURE__ */ new Set();
1348
+ let initialRestorePending = false;
1349
+ const touchedKeys = /* @__PURE__ */ new Set();
1350
+ function reportError2(error) {
1351
+ if (opts.onError) {
1352
+ opts.onError(error);
1353
+ } else {
1354
+ console.error("State persistence error:", error);
1355
+ }
1356
+ }
869
1357
  function filterKeys(obj) {
870
1358
  if (!obj || typeof obj !== "object") return obj;
871
1359
  if (opts.include && Array.isArray(opts.include)) {
@@ -886,11 +1374,31 @@ function createPersistentState(initialState = {}, options = {}) {
886
1374
  }
887
1375
  return obj;
888
1376
  }
889
- async function save(immediate = false) {
890
- if (opts.debounce && !immediate) {
891
- clearTimeout(saveTimeout);
892
- saveTimeout = setTimeout(() => save(true), opts.debounceDelay);
893
- return;
1377
+ let channel = null;
1378
+ if (opts.crossTab && !onServer && typeof BroadcastChannel !== "undefined") {
1379
+ channel = new BroadcastChannel(`coherent-state-sync:${opts.key}`);
1380
+ channel.onmessage = (event) => {
1381
+ const message = event.data;
1382
+ if (destroyed || !message || message.type !== "state-update" || message.source === instanceId) {
1383
+ return;
1384
+ }
1385
+ const oldState = { ...state };
1386
+ state = { ...state, ...message.state };
1387
+ notifyListeners(oldState, state);
1388
+ };
1389
+ channel.unref?.();
1390
+ }
1391
+ function broadcast(filteredState) {
1392
+ if (!channel) return;
1393
+ try {
1394
+ channel.postMessage({ type: "state-update", source: instanceId, state: filteredState });
1395
+ } catch (error) {
1396
+ reportError2(error);
1397
+ }
1398
+ }
1399
+ async function write() {
1400
+ if (adapter.available === false) {
1401
+ return false;
894
1402
  }
895
1403
  try {
896
1404
  const filteredState = filterKeys(state);
@@ -902,31 +1410,45 @@ function createPersistentState(initialState = {}, options = {}) {
902
1410
  ttl: opts.ttl
903
1411
  };
904
1412
  let dataString = JSON.stringify(data);
905
- if (encryption) {
906
- dataString = encryption.encrypt(dataString);
1413
+ if (obfuscation) {
1414
+ dataString = obfuscation.encode(dataString);
1415
+ }
1416
+ const stored = await adapter.set(opts.key, dataString);
1417
+ if (stored === false) {
1418
+ throw new Error(`State "${opts.key}" could not be written to storage`);
907
1419
  }
908
- await adapter.set(opts.key, dataString);
909
1420
  if (opts.onSave) {
910
1421
  opts.onSave(filteredState);
911
1422
  }
912
- if (opts.crossTab && typeof BroadcastChannel !== "undefined") {
913
- const channel = new BroadcastChannel("coherent-state-sync");
914
- channel.postMessage({ type: "state-update", state: filteredState });
915
- channel.close();
916
- }
1423
+ broadcast(filteredState);
1424
+ return true;
917
1425
  } catch (error) {
918
- console.error("State save error:", error);
919
- if (opts.onError) {
920
- opts.onError(error);
921
- }
1426
+ reportError2(error);
1427
+ return false;
922
1428
  }
923
1429
  }
1430
+ function save(immediate = false) {
1431
+ if (destroyed) {
1432
+ return Promise.resolve(false);
1433
+ }
1434
+ if (opts.debounce && !immediate) {
1435
+ clearTimeout(saveTimeout);
1436
+ saveTimeout = setTimeout(() => {
1437
+ saveTimeout = null;
1438
+ write();
1439
+ }, opts.debounceDelay);
1440
+ return void 0;
1441
+ }
1442
+ clearTimeout(saveTimeout);
1443
+ saveTimeout = null;
1444
+ return write();
1445
+ }
924
1446
  async function load() {
925
1447
  try {
926
1448
  let dataString = await adapter.get(opts.key);
927
1449
  if (!dataString) return null;
928
- if (encryption) {
929
- dataString = encryption.decrypt(dataString);
1450
+ if (obfuscation) {
1451
+ dataString = obfuscation.decode(dataString);
930
1452
  }
931
1453
  const data = JSON.parse(dataString);
932
1454
  if (data.ttl && data.timestamp) {
@@ -949,10 +1471,7 @@ function createPersistentState(initialState = {}, options = {}) {
949
1471
  }
950
1472
  return loadedState;
951
1473
  } catch (error) {
952
- console.error("State load error:", error);
953
- if (opts.onError) {
954
- opts.onError(error);
955
- }
1474
+ reportError2(error);
956
1475
  return null;
957
1476
  }
958
1477
  }
@@ -969,6 +1488,20 @@ function createPersistentState(initialState = {}, options = {}) {
969
1488
  }
970
1489
  });
971
1490
  }
1491
+ function applyLoaded(loaded, skipTouched) {
1492
+ if (!loaded || typeof loaded !== "object") {
1493
+ return false;
1494
+ }
1495
+ const updates = Object.fromEntries(
1496
+ Object.entries(loaded).filter(([key]) => !skipTouched || !touchedKeys.has(key))
1497
+ );
1498
+ if (Object.keys(updates).length > 0) {
1499
+ const oldState = { ...state };
1500
+ state = { ...state, ...updates };
1501
+ notifyListeners(oldState, state);
1502
+ }
1503
+ return true;
1504
+ }
972
1505
  function getState(key) {
973
1506
  return key ? state[key] : { ...state };
974
1507
  }
@@ -977,6 +1510,9 @@ function createPersistentState(initialState = {}, options = {}) {
977
1510
  if (typeof updates === "function") {
978
1511
  updates = updates(oldState);
979
1512
  }
1513
+ if (initialRestorePending && updates && typeof updates === "object") {
1514
+ for (const key of Object.keys(updates)) touchedKeys.add(key);
1515
+ }
980
1516
  state = { ...state, ...updates };
981
1517
  notifyListeners(oldState, state);
982
1518
  if (persist2) {
@@ -985,6 +1521,10 @@ function createPersistentState(initialState = {}, options = {}) {
985
1521
  }
986
1522
  function resetState(persist2 = true) {
987
1523
  const oldState = { ...state };
1524
+ if (initialRestorePending) {
1525
+ for (const key of Object.keys(oldState)) touchedKeys.add(key);
1526
+ for (const key of Object.keys(initialState)) touchedKeys.add(key);
1527
+ }
988
1528
  state = { ...initialState };
989
1529
  notifyListeners(oldState, state);
990
1530
  if (persist2) {
@@ -992,33 +1532,44 @@ function createPersistentState(initialState = {}, options = {}) {
992
1532
  }
993
1533
  }
994
1534
  async function clearStorage() {
995
- await adapter.remove(opts.key);
1535
+ try {
1536
+ await adapter.remove(opts.key);
1537
+ } catch (error) {
1538
+ reportError2(error);
1539
+ }
996
1540
  }
997
1541
  async function persist() {
998
- await save(true);
1542
+ return save(true);
999
1543
  }
1000
1544
  async function restore() {
1001
- const loaded = await load();
1002
- if (loaded) {
1003
- const oldState = { ...state };
1004
- state = { ...state, ...loaded };
1005
- notifyListeners(oldState, state);
1006
- return true;
1007
- }
1008
- return false;
1009
- }
1010
- if (opts.crossTab && typeof BroadcastChannel !== "undefined") {
1011
- const channel = new BroadcastChannel("coherent-state-sync");
1012
- channel.onmessage = (event) => {
1013
- if (event.data.type === "state-update") {
1014
- const oldState = { ...state };
1015
- state = { ...state, ...event.data.state };
1016
- notifyListeners(oldState, state);
1017
- }
1018
- };
1019
- }
1020
- if (opts.storage !== "memory") {
1021
- restore();
1545
+ return applyLoaded(await load(), false);
1546
+ }
1547
+ async function destroy() {
1548
+ if (destroyed) return;
1549
+ const pending = saveTimeout !== null;
1550
+ clearTimeout(saveTimeout);
1551
+ saveTimeout = null;
1552
+ if (pending) {
1553
+ await write();
1554
+ }
1555
+ destroyed = true;
1556
+ channel?.close();
1557
+ channel = null;
1558
+ listeners.clear();
1559
+ }
1560
+ let ready;
1561
+ if (opts.storage !== "memory" || opts.adapter) {
1562
+ initialRestorePending = true;
1563
+ ready = load().then((loaded) => {
1564
+ initialRestorePending = false;
1565
+ if (destroyed) return false;
1566
+ const restored = applyLoaded(loaded, true);
1567
+ if (restored && touchedKeys.size > 0) save();
1568
+ touchedKeys.clear();
1569
+ return restored;
1570
+ });
1571
+ } else {
1572
+ ready = Promise.resolve(false);
1022
1573
  }
1023
1574
  return {
1024
1575
  getState,
@@ -1030,6 +1581,9 @@ function createPersistentState(initialState = {}, options = {}) {
1030
1581
  clearStorage,
1031
1582
  load,
1032
1583
  save: () => save(true),
1584
+ destroy,
1585
+ /** Settles once the automatic restore on creation is done */
1586
+ ready,
1033
1587
  get adapter() {
1034
1588
  return adapter;
1035
1589
  }
@@ -1063,6 +1617,61 @@ var EMAIL_MAX_LENGTH = 254;
1063
1617
  function isEmailShaped(value) {
1064
1618
  return typeof value === "string" && value.length <= EMAIL_MAX_LENGTH && EMAIL_PATTERN.test(value);
1065
1619
  }
1620
+ function matchesType(value, type) {
1621
+ switch (type) {
1622
+ case "array":
1623
+ return Array.isArray(value);
1624
+ case "null":
1625
+ return value === null;
1626
+ case "object":
1627
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1628
+ case "integer":
1629
+ return typeof value === "number" && Number.isInteger(value);
1630
+ case "number":
1631
+ return typeof value === "number" && !Number.isNaN(value);
1632
+ default:
1633
+ return typeof value === type;
1634
+ }
1635
+ }
1636
+ function describeValue(value) {
1637
+ if (value === null) return "null";
1638
+ if (Array.isArray(value)) return "array";
1639
+ if (typeof value === "string") return JSON.stringify(value);
1640
+ return typeof value === "object" ? "object" : String(value);
1641
+ }
1642
+ function coerceTo(value, type) {
1643
+ switch (type) {
1644
+ case "string":
1645
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
1646
+ return { ok: !Number.isNaN(value), value: String(value) };
1647
+ }
1648
+ return { ok: false };
1649
+ case "number":
1650
+ case "integer": {
1651
+ let number;
1652
+ if (typeof value === "string" && value.trim() !== "") {
1653
+ number = Number(value);
1654
+ } else if (typeof value === "boolean") {
1655
+ number = value ? 1 : 0;
1656
+ } else {
1657
+ return { ok: false };
1658
+ }
1659
+ const valid = type === "integer" ? Number.isInteger(number) : !Number.isNaN(number);
1660
+ return valid ? { ok: true, value: number } : { ok: false };
1661
+ }
1662
+ case "boolean":
1663
+ if (typeof value === "string") {
1664
+ const normalized = value.trim().toLowerCase();
1665
+ if (normalized === "true" || normalized === "1") return { ok: true, value: true };
1666
+ if (normalized === "false" || normalized === "0" || normalized === "") return { ok: true, value: false };
1667
+ return { ok: false };
1668
+ }
1669
+ if (value === 1 || value === 0) return { ok: true, value: value === 1 };
1670
+ return { ok: false };
1671
+ default:
1672
+ return { ok: false };
1673
+ }
1674
+ }
1066
1675
  var SchemaValidator = class {
1067
1676
  constructor(schema, options = {}) {
1068
1677
  this.schema = schema;
@@ -1085,10 +1694,7 @@ var SchemaValidator = class {
1085
1694
  if (schema.type) {
1086
1695
  const typeResult = this.validateType(value, schema.type, path);
1087
1696
  if (!typeResult.valid) {
1088
- errors.push(...typeResult.errors);
1089
- if (!this.options.coerce) {
1090
- return { valid: false, errors, value };
1091
- }
1697
+ return { valid: false, errors: typeResult.errors, value };
1092
1698
  }
1093
1699
  coercedValue = typeResult.value;
1094
1700
  }
@@ -1146,47 +1752,17 @@ var SchemaValidator = class {
1146
1752
  const errors = [];
1147
1753
  let coercedValue = value;
1148
1754
  const types = Array.isArray(type) ? type : [type];
1149
- const isValid = types.some((t) => {
1150
- if (t === "array") return Array.isArray(value);
1151
- if (t === "null") return value === null;
1152
- if (t === "integer") return typeof value === "number" && Number.isInteger(value);
1153
- return typeof value === t;
1154
- });
1755
+ const isValid = types.some((t) => matchesType(value, t));
1155
1756
  if (!isValid) {
1156
1757
  if (this.options.coerce) {
1157
1758
  const primaryType = types[0];
1158
- try {
1159
- if (primaryType === "string") {
1160
- coercedValue = String(value);
1161
- } else if (primaryType === "number") {
1162
- coercedValue = Number(value);
1163
- if (isNaN(coercedValue)) {
1164
- errors.push({
1165
- path,
1166
- message: `Cannot coerce "${value}" to number`,
1167
- type: "type",
1168
- value,
1169
- expected: primaryType
1170
- });
1171
- }
1172
- } else if (primaryType === "boolean") {
1173
- coercedValue = Boolean(value);
1174
- } else if (primaryType === "integer") {
1175
- coercedValue = parseInt(value, 10);
1176
- if (isNaN(coercedValue)) {
1177
- errors.push({
1178
- path,
1179
- message: `Cannot coerce "${value}" to integer`,
1180
- type: "type",
1181
- value,
1182
- expected: primaryType
1183
- });
1184
- }
1185
- }
1186
- } catch {
1759
+ const coerced = coerceTo(value, primaryType);
1760
+ if (coerced.ok) {
1761
+ coercedValue = coerced.value;
1762
+ } else {
1187
1763
  errors.push({
1188
1764
  path,
1189
- message: `Cannot coerce value to ${primaryType}`,
1765
+ message: `Cannot coerce ${describeValue(value)} to ${primaryType}`,
1190
1766
  type: "type",
1191
1767
  value,
1192
1768
  expected: primaryType
@@ -1407,7 +1983,7 @@ var SchemaValidator = class {
1407
1983
  }
1408
1984
  });
1409
1985
  }
1410
- if (schema.additionalProperties === false && !this.options.allowUnknown) {
1986
+ if (schema.additionalProperties === false || !this.options.allowUnknown && schema.properties) {
1411
1987
  const allowedProps = new Set(Object.keys(schema.properties || {}));
1412
1988
  Object.keys(value).forEach((prop) => {
1413
1989
  if (!allowedProps.has(prop)) {
@@ -1889,7 +2465,16 @@ var ModalState = class {
1889
2465
  this._currentId = 0;
1890
2466
  }
1891
2467
  // OOP methods for modal control
2468
+ /**
2469
+ * Open the modal with `data`. Resolves with the value passed to close().
2470
+ * Opening again while open replaces the modal: the earlier promise
2471
+ * resolves with `null`, as if it had been closed without a result.
2472
+ */
1892
2473
  async open(data) {
2474
+ for (const [id, resolver] of this._resolvers) {
2475
+ resolver(null);
2476
+ this._resolvers.delete(id);
2477
+ }
1893
2478
  return new Promise((resolve) => {
1894
2479
  const id = ++this._currentId;
1895
2480
  this._resolvers.set(id, resolve);
@@ -1991,6 +2576,7 @@ var index_default = {
1991
2576
  createReactiveState,
1992
2577
  observable,
1993
2578
  computed,
2579
+ batch,
1994
2580
  // SSR-compatible state management
1995
2581
  createState,
1996
2582
  globalStateManager,
@@ -1999,6 +2585,7 @@ var index_default = {
1999
2585
  restoreContext,
2000
2586
  clearAllContexts,
2001
2587
  useContext,
2588
+ runWithContext,
2002
2589
  // Persistence utilities
2003
2590
  createPersistentState,
2004
2591
  withLocalStorage,
@@ -2023,6 +2610,7 @@ export {
2023
2610
  ReactiveState,
2024
2611
  RouterState,
2025
2612
  StateError,
2613
+ batch,
2026
2614
  clearAllContexts,
2027
2615
  computed,
2028
2616
  createContextProvider,
@@ -2040,6 +2628,7 @@ export {
2040
2628
  observable,
2041
2629
  provideContext,
2042
2630
  restoreContext,
2631
+ runWithContext,
2043
2632
  stateUtils,
2044
2633
  useContext,
2045
2634
  validators,