@pyreon/reactivity 0.1.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/lib/index.js ADDED
@@ -0,0 +1,838 @@
1
+ //#region src/batch.ts
2
+ let batchDepth = 0;
3
+ let pendingNotifications = /* @__PURE__ */ new Set();
4
+ function batch(fn) {
5
+ batchDepth++;
6
+ try {
7
+ fn();
8
+ } finally {
9
+ batchDepth--;
10
+ if (batchDepth === 0) {
11
+ const flush = pendingNotifications;
12
+ pendingNotifications = /* @__PURE__ */ new Set();
13
+ for (const notify of flush) notify();
14
+ }
15
+ }
16
+ }
17
+ function isBatching() {
18
+ return batchDepth > 0;
19
+ }
20
+ function enqueuePendingNotification(notify) {
21
+ pendingNotifications.add(notify);
22
+ }
23
+ /**
24
+ * Returns a Promise that resolves after all currently-pending microtasks have flushed.
25
+ * Useful when you need to read the DOM after a batch of signal updates has settled.
26
+ *
27
+ * @example
28
+ * count.set(1); count.set(2)
29
+ * await nextTick()
30
+ * // DOM is now up-to-date
31
+ */
32
+ function nextTick() {
33
+ return new Promise((resolve) => queueMicrotask(resolve));
34
+ }
35
+
36
+ //#endregion
37
+ //#region src/cell.ts
38
+ /**
39
+ * Lightweight reactive cell — class-based alternative to signal().
40
+ *
41
+ * - 1 object allocation vs signal()'s 6 closures
42
+ * - Same API surface: peek(), set(), update(), subscribe(), listen()
43
+ * - NOT callable as a getter (no effect tracking) — use for fixed subscriptions
44
+ * - Methods on prototype, shared across all instances
45
+ * - Single-listener fast path: no Set allocated when ≤1 subscriber
46
+ *
47
+ * Use when you need reactive state but don't need automatic effect dependency tracking.
48
+ * Ideal for list item labels in keyed reconcilers where subscribe() is used directly.
49
+ */
50
+ var Cell = class {
51
+ /** @internal */ _v;
52
+ /** @internal */ _l = null;
53
+ /** @internal */ _s = null;
54
+ constructor(value) {
55
+ this._v = value;
56
+ }
57
+ peek() {
58
+ return this._v;
59
+ }
60
+ set(value) {
61
+ if (Object.is(this._v, value)) return;
62
+ this._v = value;
63
+ if (this._l) this._l();
64
+ else if (this._s) for (const fn of this._s) fn();
65
+ }
66
+ update(fn) {
67
+ this.set(fn(this._v));
68
+ }
69
+ /**
70
+ * Fire-and-forget subscription — no unsubscribe returned.
71
+ * Use when the listener's lifetime matches the cell's (e.g., list rows).
72
+ * Saves 1 closure allocation per call vs subscribe().
73
+ */
74
+ listen(listener) {
75
+ if (!this._l && !this._s) this._l = listener;
76
+ else {
77
+ if (!this._s) {
78
+ this._s = /* @__PURE__ */ new Set();
79
+ if (this._l) {
80
+ this._s.add(this._l);
81
+ this._l = null;
82
+ }
83
+ }
84
+ this._s.add(listener);
85
+ }
86
+ }
87
+ subscribe(listener) {
88
+ this.listen(listener);
89
+ if (this._l === listener) return () => {
90
+ if (this._l === listener) this._l = null;
91
+ };
92
+ return () => this._s?.delete(listener);
93
+ }
94
+ };
95
+ function cell(value) {
96
+ return new Cell(value);
97
+ }
98
+
99
+ //#endregion
100
+ //#region src/scope.ts
101
+ var EffectScope = class {
102
+ _effects = [];
103
+ _active = true;
104
+ _updateHooks = [];
105
+ _updatePending = false;
106
+ /** Register an effect/computed to be disposed when this scope stops. */
107
+ add(e) {
108
+ if (this._active) this._effects.push(e);
109
+ }
110
+ /**
111
+ * Temporarily re-activate this scope so effects created inside `fn` are
112
+ * auto-tracked and will be disposed when the scope stops.
113
+ * Used to ensure effects created in `onMount` callbacks belong to their
114
+ * component's scope rather than leaking as global effects.
115
+ */
116
+ runInScope(fn) {
117
+ const prev = _currentScope;
118
+ _currentScope = this;
119
+ try {
120
+ return fn();
121
+ } finally {
122
+ _currentScope = prev;
123
+ }
124
+ }
125
+ /** Register a callback to run after any reactive update in this scope. */
126
+ addUpdateHook(fn) {
127
+ this._updateHooks.push(fn);
128
+ }
129
+ /**
130
+ * Called by effects after each non-initial re-run.
131
+ * Schedules onUpdate hooks via microtask so all synchronous effects settle first.
132
+ */
133
+ notifyEffectRan() {
134
+ if (!this._active || this._updateHooks.length === 0 || this._updatePending) return;
135
+ this._updatePending = true;
136
+ queueMicrotask(() => {
137
+ this._updatePending = false;
138
+ if (!this._active) return;
139
+ for (const fn of this._updateHooks) try {
140
+ fn();
141
+ } catch (err) {
142
+ console.error("[pyreon] onUpdate hook error:", err);
143
+ }
144
+ });
145
+ }
146
+ /** Dispose all tracked effects. */
147
+ stop() {
148
+ if (!this._active) return;
149
+ for (const e of this._effects) e.dispose();
150
+ this._effects = [];
151
+ this._updateHooks = [];
152
+ this._updatePending = false;
153
+ this._active = false;
154
+ }
155
+ };
156
+ let _currentScope = null;
157
+ function getCurrentScope() {
158
+ return _currentScope;
159
+ }
160
+ function setCurrentScope(scope) {
161
+ _currentScope = scope;
162
+ }
163
+ /** Create a new EffectScope. */
164
+ function effectScope() {
165
+ return new EffectScope();
166
+ }
167
+
168
+ //#endregion
169
+ //#region src/tracking.ts
170
+ let activeEffect = null;
171
+ const effectDeps = /* @__PURE__ */ new WeakMap();
172
+ let _depsCollector = null;
173
+ function setDepsCollector(collector) {
174
+ _depsCollector = collector;
175
+ }
176
+ /**
177
+ * Register the active effect as a subscriber of the given reactive source.
178
+ * The subscriber Set is created lazily on the host — sources read only outside
179
+ * effects never allocate a Set.
180
+ */
181
+ function trackSubscriber(host) {
182
+ if (activeEffect) {
183
+ if (!host._s) host._s = /* @__PURE__ */ new Set();
184
+ const subscribers = host._s;
185
+ subscribers.add(activeEffect);
186
+ if (_depsCollector) _depsCollector.push(subscribers);
187
+ else {
188
+ let deps = effectDeps.get(activeEffect);
189
+ if (!deps) {
190
+ deps = /* @__PURE__ */ new Set();
191
+ effectDeps.set(activeEffect, deps);
192
+ }
193
+ deps.add(subscribers);
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * Remove an effect from every subscriber set it was registered in,
199
+ * then clear its dep record. Call this before each re-run and on dispose.
200
+ */
201
+ function cleanupEffect(fn) {
202
+ const deps = effectDeps.get(fn);
203
+ if (deps) {
204
+ for (const sub of deps) sub.delete(fn);
205
+ deps.clear();
206
+ }
207
+ }
208
+ function notifySubscribers(subscribers) {
209
+ if (subscribers.size === 0) return;
210
+ if (subscribers.size === 1) {
211
+ const sub = subscribers.values().next().value;
212
+ if (isBatching()) enqueuePendingNotification(sub);
213
+ else sub();
214
+ return;
215
+ }
216
+ if (isBatching()) for (const sub of subscribers) enqueuePendingNotification(sub);
217
+ else for (const sub of [...subscribers]) sub();
218
+ }
219
+ function withTracking(fn, compute) {
220
+ const prev = activeEffect;
221
+ activeEffect = fn;
222
+ try {
223
+ return compute();
224
+ } finally {
225
+ activeEffect = prev;
226
+ }
227
+ }
228
+ function runUntracked(fn) {
229
+ const prev = activeEffect;
230
+ activeEffect = null;
231
+ try {
232
+ return fn();
233
+ } finally {
234
+ activeEffect = prev;
235
+ }
236
+ }
237
+
238
+ //#endregion
239
+ //#region src/computed.ts
240
+ function computed(fn, options) {
241
+ let value;
242
+ let dirty = true;
243
+ let initialized = false;
244
+ let disposed = false;
245
+ const customEquals = options?.equals;
246
+ const host = { _s: null };
247
+ const recompute = () => {
248
+ if (disposed) return;
249
+ cleanupEffect(recompute);
250
+ if (customEquals) {
251
+ const next = withTracking(recompute, fn);
252
+ if (initialized && customEquals(value, next)) return;
253
+ value = next;
254
+ dirty = false;
255
+ initialized = true;
256
+ if (host._s) notifySubscribers(host._s);
257
+ } else {
258
+ dirty = true;
259
+ if (host._s) notifySubscribers(host._s);
260
+ }
261
+ };
262
+ const read = () => {
263
+ trackSubscriber(host);
264
+ if (dirty) {
265
+ value = withTracking(recompute, fn);
266
+ dirty = false;
267
+ initialized = true;
268
+ }
269
+ return value;
270
+ };
271
+ read.dispose = () => {
272
+ disposed = true;
273
+ cleanupEffect(recompute);
274
+ };
275
+ getCurrentScope()?.add({ dispose: read.dispose });
276
+ return read;
277
+ }
278
+
279
+ //#endregion
280
+ //#region src/effect.ts
281
+ let _errorHandler = (err) => {
282
+ console.error("[pyreon] Unhandled effect error:", err);
283
+ };
284
+ function setErrorHandler(fn) {
285
+ _errorHandler = fn;
286
+ }
287
+ function effect(fn) {
288
+ const scope = getCurrentScope();
289
+ let disposed = false;
290
+ let isFirstRun = true;
291
+ let cleanup;
292
+ const runCleanup = () => {
293
+ if (typeof cleanup === "function") {
294
+ try {
295
+ cleanup();
296
+ } catch (err) {
297
+ _errorHandler(err);
298
+ }
299
+ cleanup = void 0;
300
+ }
301
+ };
302
+ const run = () => {
303
+ if (disposed) return;
304
+ runCleanup();
305
+ cleanupEffect(run);
306
+ try {
307
+ cleanup = withTracking(run, fn) || void 0;
308
+ } catch (err) {
309
+ _errorHandler(err);
310
+ }
311
+ if (!isFirstRun) scope?.notifyEffectRan();
312
+ isFirstRun = false;
313
+ };
314
+ run();
315
+ const e = { dispose() {
316
+ runCleanup();
317
+ disposed = true;
318
+ cleanupEffect(run);
319
+ } };
320
+ getCurrentScope()?.add(e);
321
+ return e;
322
+ }
323
+ /**
324
+ * Lightweight effect for DOM render bindings.
325
+ *
326
+ * Differences from `effect()`:
327
+ * - No EffectScope registration (caller owns the dispose lifecycle)
328
+ * - No error handler (errors propagate naturally)
329
+ * - No onUpdate notification
330
+ * - Deps stored in a local array instead of the global WeakMap — faster
331
+ * creation and disposal (~200ns saved per effect vs WeakMap path)
332
+ *
333
+ * Returns a dispose function (not an Effect object — saves 1 allocation).
334
+ */
335
+ /**
336
+ * Static-dep binding — compiler helper for template expressions.
337
+ *
338
+ * Like renderEffect but assumes dependencies never change (true for all
339
+ * compiler-emitted template bindings like `_tpl()` text/attribute updates).
340
+ *
341
+ * Tracks dependencies only on the first run. Re-runs skip cleanup, re-tracking,
342
+ * and tracking context save/restore entirely — just calls `fn()` directly.
343
+ *
344
+ * Per re-run savings vs renderEffect:
345
+ * - No deps iteration + Set.delete (cleanup)
346
+ * - No setDepsCollector + withTracking (re-registration)
347
+ * - Signal reads hit `if (activeEffect)` null check → instant return
348
+ */
349
+ function _bind(fn) {
350
+ const deps = [];
351
+ let disposed = false;
352
+ const run = () => {
353
+ if (disposed) return;
354
+ fn();
355
+ };
356
+ setDepsCollector(deps);
357
+ withTracking(run, fn);
358
+ setDepsCollector(null);
359
+ const dispose = () => {
360
+ if (disposed) return;
361
+ disposed = true;
362
+ for (const s of deps) s.delete(run);
363
+ deps.length = 0;
364
+ };
365
+ getCurrentScope()?.add({ dispose });
366
+ return dispose;
367
+ }
368
+ function renderEffect(fn) {
369
+ const deps = [];
370
+ let disposed = false;
371
+ const run = () => {
372
+ if (disposed) return;
373
+ for (const s of deps) s.delete(run);
374
+ deps.length = 0;
375
+ setDepsCollector(deps);
376
+ withTracking(run, fn);
377
+ setDepsCollector(null);
378
+ };
379
+ run();
380
+ const dispose = () => {
381
+ if (disposed) return;
382
+ disposed = true;
383
+ for (const s of deps) s.delete(run);
384
+ deps.length = 0;
385
+ };
386
+ getCurrentScope()?.add({ dispose });
387
+ return dispose;
388
+ }
389
+
390
+ //#endregion
391
+ //#region src/createSelector.ts
392
+ /**
393
+ * Create an equality selector — returns a reactive predicate that is true
394
+ * only for the currently selected value.
395
+ *
396
+ * Unlike a plain `() => source() === value`, this only triggers the TWO
397
+ * affected subscribers (deselected + newly selected) instead of ALL
398
+ * subscribers, making selection O(1) regardless of list size.
399
+ *
400
+ * @example
401
+ * const isSelected = createSelector(selectedId)
402
+ * // In each row:
403
+ * class: () => (isSelected(row.id) ? "selected" : "")
404
+ */
405
+ function createSelector(source) {
406
+ const subs = /* @__PURE__ */ new Map();
407
+ let current;
408
+ let initialized = false;
409
+ effect(() => {
410
+ const next = source();
411
+ if (!initialized) {
412
+ initialized = true;
413
+ current = next;
414
+ return;
415
+ }
416
+ if (Object.is(next, current)) return;
417
+ const old = current;
418
+ current = next;
419
+ const oldBucket = subs.get(old);
420
+ const newBucket = subs.get(next);
421
+ if (oldBucket) for (const fn of [...oldBucket]) fn();
422
+ if (newBucket) for (const fn of [...newBucket]) fn();
423
+ });
424
+ const hosts = /* @__PURE__ */ new Map();
425
+ return (value) => {
426
+ let host = hosts.get(value);
427
+ if (!host) {
428
+ let bucket = subs.get(value);
429
+ if (!bucket) {
430
+ bucket = /* @__PURE__ */ new Set();
431
+ subs.set(value, bucket);
432
+ }
433
+ host = { _s: bucket };
434
+ hosts.set(value, host);
435
+ }
436
+ trackSubscriber(host);
437
+ return Object.is(current, value);
438
+ };
439
+ }
440
+
441
+ //#endregion
442
+ //#region src/debug.ts
443
+ let _traceListeners = null;
444
+ /**
445
+ * Register a listener that fires on every signal write.
446
+ * Returns a dispose function.
447
+ *
448
+ * @example
449
+ * const dispose = onSignalUpdate(e => {
450
+ * console.log(`${e.name ?? 'anonymous'}: ${e.prev} → ${e.next}`)
451
+ * })
452
+ */
453
+ function onSignalUpdate(listener) {
454
+ if (!_traceListeners) _traceListeners = [];
455
+ _traceListeners.push(listener);
456
+ return () => {
457
+ if (!_traceListeners) return;
458
+ _traceListeners = _traceListeners.filter((l) => l !== listener);
459
+ if (_traceListeners.length === 0) _traceListeners = null;
460
+ };
461
+ }
462
+ /** @internal — called from signal.set() when tracing is active */
463
+ function _notifyTraceListeners(sig, prev, next) {
464
+ if (!_traceListeners) return;
465
+ const event = {
466
+ signal: sig,
467
+ name: sig.label,
468
+ prev,
469
+ next,
470
+ stack: (/* @__PURE__ */ new Error()).stack ?? "",
471
+ timestamp: performance.now()
472
+ };
473
+ for (const l of _traceListeners) l(event);
474
+ }
475
+ /** Check if any trace listeners are active (fast path for signal.set) */
476
+ function isTracing() {
477
+ return _traceListeners !== null;
478
+ }
479
+ let _whyActive = false;
480
+ let _whyLog = [];
481
+ /**
482
+ * Trace the next signal update. Logs which signals fire and what changed.
483
+ * Call before triggering a state change to see what updates and why.
484
+ *
485
+ * @example
486
+ * why()
487
+ * count.set(5)
488
+ * // Console: [pyreon:why] "count": 3 → 5 (2 subscribers)
489
+ */
490
+ function why() {
491
+ if (_whyActive) return;
492
+ _whyActive = true;
493
+ _whyLog = [];
494
+ const dispose = onSignalUpdate((e) => {
495
+ const _subCount = e.signal._s?.size ?? 0;
496
+ const _name = e.name ? `"${e.name}"` : "(anonymous signal)";
497
+ console.log(`[pyreon:why] ${_name}: ${JSON.stringify(e.prev)} → ${JSON.stringify(e.next)} (${_subCount} subscriber${_subCount === 1 ? "" : "s"})`);
498
+ _whyLog.push({
499
+ name: e.name,
500
+ prev: e.prev,
501
+ next: e.next
502
+ });
503
+ });
504
+ queueMicrotask(() => {
505
+ dispose();
506
+ if (_whyLog.length === 0) console.log("[pyreon:why] No signal updates detected");
507
+ _whyActive = false;
508
+ _whyLog = [];
509
+ });
510
+ }
511
+ /**
512
+ * Print a signal's current state to the console in a readable format.
513
+ *
514
+ * @example
515
+ * const count = signal(42, { name: "count" })
516
+ * inspectSignal(count)
517
+ * // Console:
518
+ * // 🔍 Signal "count"
519
+ * // value: 42
520
+ * // subscribers: 3
521
+ */
522
+ function inspectSignal(sig) {
523
+ const info = sig.debug();
524
+ console.group(`🔍 Signal ${info.name ? `"${info.name}"` : "(anonymous)"}`);
525
+ console.log("value:", info.value);
526
+ console.log("subscribers:", info.subscriberCount);
527
+ console.groupEnd();
528
+ return info;
529
+ }
530
+
531
+ //#endregion
532
+ //#region src/signal.ts
533
+ function _peek() {
534
+ return this._v;
535
+ }
536
+ function _set(newValue) {
537
+ if (Object.is(this._v, newValue)) return;
538
+ const prev = this._v;
539
+ this._v = newValue;
540
+ if (isTracing()) _notifyTraceListeners(this, prev, newValue);
541
+ if (this._s) notifySubscribers(this._s);
542
+ }
543
+ function _update(fn) {
544
+ _set.call(this, fn(this._v));
545
+ }
546
+ function _subscribe(listener) {
547
+ if (!this._s) this._s = /* @__PURE__ */ new Set();
548
+ this._s.add(listener);
549
+ return () => this._s?.delete(listener);
550
+ }
551
+ function _debug() {
552
+ return {
553
+ name: this._n,
554
+ value: this._v,
555
+ subscriberCount: this._s?.size ?? 0
556
+ };
557
+ }
558
+ const _labelDescriptor = {
559
+ get() {
560
+ return this._n;
561
+ },
562
+ set(v) {
563
+ this._n = v;
564
+ },
565
+ enumerable: false,
566
+ configurable: true
567
+ };
568
+ /**
569
+ * Create a reactive signal.
570
+ *
571
+ * Only 1 closure is allocated (the read function). State is stored as
572
+ * properties on the function object (_v, _s) and methods (peek, set,
573
+ * update, subscribe) are shared across all signals — not per-signal closures.
574
+ */
575
+ function signal(initialValue, options) {
576
+ const read = (() => {
577
+ trackSubscriber(read);
578
+ return read._v;
579
+ });
580
+ read._v = initialValue;
581
+ read._s = null;
582
+ read._n = options?.name;
583
+ read.peek = _peek;
584
+ read.set = _set;
585
+ read.update = _update;
586
+ read.subscribe = _subscribe;
587
+ read.debug = _debug;
588
+ Object.defineProperty(read, "label", _labelDescriptor);
589
+ return read;
590
+ }
591
+
592
+ //#endregion
593
+ //#region src/store.ts
594
+ /**
595
+ * createStore — deep reactive Proxy store.
596
+ *
597
+ * Wraps a plain object/array in a Proxy that creates a fine-grained signal for
598
+ * every property. Direct mutations (`store.count++`, `store.items[0].label = "x"`)
599
+ * trigger only the signals for the mutated properties — not the whole tree.
600
+ *
601
+ * @example
602
+ * const state = createStore({ count: 0, items: [{ id: 1, text: "hello" }] })
603
+ *
604
+ * effect(() => console.log(state.count)) // tracks state.count only
605
+ * state.count++ // only the count effect re-runs
606
+ * state.items[0].text = "world" // only text-tracking effects re-run
607
+ */
608
+ const proxyCache = /* @__PURE__ */ new WeakMap();
609
+ const IS_STORE = Symbol("pyreon.store");
610
+ /** Returns true if the value is a createStore proxy. */
611
+ function isStore(value) {
612
+ return value !== null && typeof value === "object" && value[IS_STORE] === true;
613
+ }
614
+ /**
615
+ * Create a deep reactive store from a plain object or array.
616
+ * Returns a proxy — mutations to the proxy trigger fine-grained reactive updates.
617
+ */
618
+ function createStore(initial) {
619
+ return wrap(initial);
620
+ }
621
+ function wrap(raw) {
622
+ const cached = proxyCache.get(raw);
623
+ if (cached) return cached;
624
+ const propSignals = /* @__PURE__ */ new Map();
625
+ const isArray = Array.isArray(raw);
626
+ const lengthSig = isArray ? signal(raw.length) : null;
627
+ function getOrCreateSignal(key) {
628
+ if (!propSignals.has(key)) propSignals.set(key, signal(raw[key]));
629
+ return propSignals.get(key);
630
+ }
631
+ const proxy = new Proxy(raw, {
632
+ get(target, key) {
633
+ if (key === IS_STORE) return true;
634
+ if (typeof key === "symbol") return target[key];
635
+ if (isArray && key === "length") return lengthSig?.();
636
+ if (!Object.hasOwn(target, key)) return target[key];
637
+ const value = getOrCreateSignal(key)();
638
+ if (value !== null && typeof value === "object") return wrap(value);
639
+ return value;
640
+ },
641
+ set(target, key, value) {
642
+ if (typeof key === "symbol") {
643
+ target[key] = value;
644
+ return true;
645
+ }
646
+ const prevLength = isArray ? target.length : 0;
647
+ target[key] = value;
648
+ if (isArray && key === "length") {
649
+ lengthSig?.set(value);
650
+ return true;
651
+ }
652
+ if (propSignals.has(key)) propSignals.get(key)?.set(value);
653
+ else propSignals.set(key, signal(value));
654
+ if (isArray && target.length !== prevLength) lengthSig?.set(target.length);
655
+ return true;
656
+ },
657
+ deleteProperty(target, key) {
658
+ delete target[key];
659
+ if (typeof key !== "symbol" && propSignals.has(key)) {
660
+ propSignals.get(key)?.set(void 0);
661
+ propSignals.delete(key);
662
+ }
663
+ if (isArray) lengthSig?.set(target.length);
664
+ return true;
665
+ },
666
+ has(target, key) {
667
+ return Reflect.has(target, key);
668
+ },
669
+ ownKeys(target) {
670
+ return Reflect.ownKeys(target);
671
+ },
672
+ getOwnPropertyDescriptor(target, key) {
673
+ return Reflect.getOwnPropertyDescriptor(target, key);
674
+ }
675
+ });
676
+ proxyCache.set(raw, proxy);
677
+ return proxy;
678
+ }
679
+
680
+ //#endregion
681
+ //#region src/reconcile.ts
682
+ /**
683
+ * reconcile — surgically diff new state into an existing createStore proxy.
684
+ *
685
+ * Instead of replacing the store root (which would trigger all downstream effects),
686
+ * reconcile walks both the new value and the store in parallel and only calls
687
+ * `.set()` on signals whose value actually changed.
688
+ *
689
+ * Ideal for applying API responses to a long-lived store:
690
+ *
691
+ * @example
692
+ * const state = createStore({ user: { name: "Alice", age: 30 }, items: [] })
693
+ *
694
+ * // API response arrives:
695
+ * reconcile({ user: { name: "Alice", age: 31 }, items: [{ id: 1 }] }, state)
696
+ * // → only state.user.age signal fires (name unchanged)
697
+ * // → state.items[0] is newly created
698
+ *
699
+ * Arrays are reconciled by index — elements at the same index are recursively
700
+ * diffed rather than replaced wholesale. Excess old elements are removed.
701
+ */
702
+ function reconcile(source, target) {
703
+ _reconcileInner(source, target, /* @__PURE__ */ new WeakSet());
704
+ }
705
+ function _reconcileInner(source, target, seen) {
706
+ if (seen.has(source)) return;
707
+ seen.add(source);
708
+ if (Array.isArray(source) && Array.isArray(target)) _reconcileArray(source, target, seen);
709
+ else _reconcileObject(source, target, seen);
710
+ }
711
+ function _reconcileArray(source, target, seen) {
712
+ const targetLen = target.length;
713
+ const sourceLen = source.length;
714
+ for (let i = 0; i < sourceLen; i++) {
715
+ const sv = source[i];
716
+ const tv = target[i];
717
+ if (i < targetLen && sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") _reconcileInner(sv, tv, seen);
718
+ else target[i] = sv;
719
+ }
720
+ if (targetLen > sourceLen) target.length = sourceLen;
721
+ }
722
+ function _reconcileObject(source, target, seen) {
723
+ const sourceKeys = Object.keys(source);
724
+ const targetKeys = new Set(Object.keys(target));
725
+ for (const key of sourceKeys) {
726
+ const sv = source[key];
727
+ const tv = target[key];
728
+ if (sv !== null && typeof sv === "object" && tv !== null && typeof tv === "object") if (isStore(tv)) _reconcileInner(sv, tv, seen);
729
+ else target[key] = sv;
730
+ else target[key] = sv;
731
+ targetKeys.delete(key);
732
+ }
733
+ for (const key of targetKeys) delete target[key];
734
+ }
735
+
736
+ //#endregion
737
+ //#region src/resource.ts
738
+ /**
739
+ * Async data primitive. Fetches data reactively whenever `source()` changes.
740
+ *
741
+ * @example
742
+ * const userId = signal(1)
743
+ * const user = createResource(userId, (id) => fetchUser(id))
744
+ * // user.data() — the fetched user (undefined while loading)
745
+ * // user.loading() — true while in flight
746
+ * // user.error() — last error
747
+ */
748
+ function createResource(source, fetcher) {
749
+ const data = signal(void 0);
750
+ const loading = signal(false);
751
+ const error = signal(void 0);
752
+ let requestId = 0;
753
+ const doFetch = (param) => {
754
+ const id = ++requestId;
755
+ loading.set(true);
756
+ error.set(void 0);
757
+ fetcher(param).then((result) => {
758
+ if (id !== requestId) return;
759
+ data.set(result);
760
+ loading.set(false);
761
+ }).catch((err) => {
762
+ if (id !== requestId) return;
763
+ error.set(err);
764
+ loading.set(false);
765
+ });
766
+ };
767
+ effect(() => {
768
+ const param = source();
769
+ runUntracked(() => doFetch(param));
770
+ });
771
+ return {
772
+ data,
773
+ loading,
774
+ error,
775
+ refetch() {
776
+ runUntracked(() => doFetch(source()));
777
+ }
778
+ };
779
+ }
780
+
781
+ //#endregion
782
+ //#region src/watch.ts
783
+ /**
784
+ * Watch a reactive source and run a callback whenever it changes.
785
+ *
786
+ * Returns a stop function that disposes the watcher.
787
+ *
788
+ * The callback receives (newValue, oldValue). On the first call (when
789
+ * `immediate` is true) oldValue is `undefined`.
790
+ *
791
+ * The callback may return a cleanup function that is called before each
792
+ * re-run and on stop — useful for cancelling async work.
793
+ *
794
+ * @example
795
+ * const stop = watch(
796
+ * () => userId(),
797
+ * async (id, prev) => {
798
+ * const data = await fetch(`/api/user/${id}`)
799
+ * setUser(await data.json())
800
+ * },
801
+ * )
802
+ * // Later: stop()
803
+ */
804
+ function watch(source, callback, opts = {}) {
805
+ let oldVal;
806
+ let isFirst = true;
807
+ let cleanupFn;
808
+ const e = effect(() => {
809
+ const newVal = source();
810
+ if (isFirst) {
811
+ isFirst = false;
812
+ oldVal = newVal;
813
+ if (opts.immediate) {
814
+ const result = callback(newVal, void 0);
815
+ if (typeof result === "function") cleanupFn = result;
816
+ }
817
+ return;
818
+ }
819
+ if (cleanupFn) {
820
+ cleanupFn();
821
+ cleanupFn = void 0;
822
+ }
823
+ const result = callback(newVal, oldVal);
824
+ if (typeof result === "function") cleanupFn = result;
825
+ oldVal = newVal;
826
+ });
827
+ return () => {
828
+ e.dispose();
829
+ if (cleanupFn) {
830
+ cleanupFn();
831
+ cleanupFn = void 0;
832
+ }
833
+ };
834
+ }
835
+
836
+ //#endregion
837
+ export { Cell, EffectScope, _bind, batch, cell, computed, createResource, createSelector, createStore, effect, effectScope, getCurrentScope, inspectSignal, isStore, nextTick, onSignalUpdate, reconcile, renderEffect, runUntracked, setCurrentScope, setErrorHandler, signal, watch, why };
838
+ //# sourceMappingURL=index.js.map