what-core 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reactive.js CHANGED
@@ -1,5 +1,11 @@
1
1
  // What Framework - Reactive Primitives
2
2
  // Signals + Effects: fine-grained reactivity without virtual DOM overhead
3
+ //
4
+ // Upgrades:
5
+ // - Topological ordering: computed/effects sorted by _level to prevent diamond glitches
6
+ // - Iterative computed evaluation: no recursion, handles 10K+ depth chains
7
+ // - Ownership tree: createRoot children auto-dispose when parent disposes
8
+ // - Performance: cached levels, lazy sort, fast-path notify, minimal allocation
3
9
 
4
10
  // Dev-mode flag — build tools can dead-code-eliminate when false
5
11
  export const __DEV__ = typeof process !== 'undefined'
@@ -17,8 +23,22 @@ export function __setDevToolsHooks(hooks) {
17
23
 
18
24
  let currentEffect = null;
19
25
  let currentRoot = null;
26
+ let currentOwner = null; // Ownership tree: tracks current owner context
27
+ let insideComputed = false; // Track whether we're inside a computed() callback (dev-mode warning)
20
28
  let batchDepth = 0;
21
29
  let pendingEffects = [];
30
+ let pendingNeedSort = false; // Track whether pendingEffects actually needs sorting
31
+
32
+ // WeakMap: subscriber Set → owning computed's inner effect (null/absent for signals)
33
+ // Used for topological level computation.
34
+ const subSetOwner = new WeakMap();
35
+
36
+ // --- Iterative Computed Evaluation State ---
37
+ // Uses a throw/catch trampoline to convert recursive computed evaluation
38
+ // to iterative. When a computed fn() reads another dirty computed, instead
39
+ // of recursing, we throw a sentinel that gets caught by the outer loop.
40
+ const NEEDS_UPSTREAM = Symbol('needs_upstream');
41
+ let iterativeEvalStack = null; // array when inside evaluation loop, null otherwise
22
42
 
23
43
  // --- Signal ---
24
44
  // A reactive value. Reading inside an effect auto-tracks the dependency.
@@ -39,19 +59,33 @@ export function signal(initial, debugName) {
39
59
  return value;
40
60
  }
41
61
  // Write
62
+ if (__DEV__ && insideComputed) {
63
+ console.warn(
64
+ '[what] Signal.set() called inside a computed function. ' +
65
+ 'This may cause infinite loops. Use effect() instead.' +
66
+ (debugName ? ` (signal: ${debugName})` : '')
67
+ );
68
+ }
42
69
  const nextVal = typeof args[0] === 'function' ? args[0](value) : args[0];
43
70
  if (Object.is(value, nextVal)) return;
44
71
  value = nextVal;
45
72
  if (__DEV__ && __devtools) __devtools.onSignalUpdate(sig);
46
- notify(subs);
73
+ if (subs.size > 0) notify(subs);
47
74
  }
48
75
 
49
76
  sig.set = (next) => {
77
+ if (__DEV__ && insideComputed) {
78
+ console.warn(
79
+ '[what] Signal.set() called inside a computed function. ' +
80
+ 'This may cause infinite loops. Use effect() instead.' +
81
+ (debugName ? ` (signal: ${debugName})` : '')
82
+ );
83
+ }
50
84
  const nextVal = typeof next === 'function' ? next(value) : next;
51
85
  if (Object.is(value, nextVal)) return;
52
86
  value = nextVal;
53
87
  if (__DEV__ && __devtools) __devtools.onSignalUpdate(sig);
54
- notify(subs);
88
+ if (subs.size > 0) notify(subs);
55
89
  };
56
90
 
57
91
  sig.peek = () => value;
@@ -74,48 +108,159 @@ export function signal(initial, debugName) {
74
108
 
75
109
  // --- Computed ---
76
110
  // Derived signal. Lazy: only recomputes when a dependency changes AND it's read.
111
+ // Topological level: max(dependency levels) + 1, computed from source signals (level 0).
77
112
 
78
113
  export function computed(fn) {
79
114
  let value, dirty = true;
80
115
  const subs = new Set();
81
116
 
82
117
  const inner = _createEffect(() => {
83
- value = fn();
84
- dirty = false;
118
+ const prevInsideComputed = insideComputed;
119
+ if (__DEV__) insideComputed = true;
120
+ try {
121
+ value = fn();
122
+ dirty = false;
123
+ } finally {
124
+ if (__DEV__) insideComputed = prevInsideComputed;
125
+ }
85
126
  }, true);
86
127
 
128
+ // Computed nodes start at level 1. Updated when graph structure changes.
129
+ inner._level = 1;
130
+ inner._computed = true;
131
+ inner._computedSubs = subs;
132
+
133
+ // Register this subscriber set as owned by this computed
134
+ subSetOwner.set(subs, inner);
135
+
136
+ // Store markDirty/isDirty closures on the inner effect for iterative eval
137
+ inner._markDirty = () => { dirty = true; };
138
+ inner._isDirty = () => dirty;
139
+
87
140
  function read() {
88
141
  if (currentEffect) {
89
142
  subs.add(currentEffect);
90
143
  currentEffect.deps.push(subs);
91
144
  }
92
- if (dirty) _runEffect(inner);
145
+ if (dirty) _evaluateComputed(inner);
93
146
  return value;
94
147
  }
95
148
 
96
149
  // When a dependency changes, mark dirty AND propagate to our subscribers.
97
- // This is how effects that read this computed know to re-run:
98
- // signal changes → computed._onNotify → computed's subs get notified.
99
150
  inner._onNotify = () => {
100
151
  dirty = true;
101
- notify(subs);
152
+ if (subs.size > 0) notify(subs);
102
153
  };
103
154
 
104
155
  read._signal = true;
105
156
  read.peek = () => {
106
- if (dirty) _runEffect(inner);
157
+ if (dirty) _evaluateComputed(inner);
107
158
  return value;
108
159
  };
109
160
 
110
161
  return read;
111
162
  }
112
163
 
164
+ // --- Iterative Computed Evaluation ---
165
+ //
166
+ // Problem: A chain of N dirty computeds causes O(N) recursive calls:
167
+ // C_N.read() → eval → fn() → C_{N-1}.read() → eval → fn() → ... → C_1.read() → eval → fn()
168
+ // This overflows the stack at ~3500 depth.
169
+ //
170
+ // Solution: Throw/catch trampoline. The outermost _evaluateComputed manages a
171
+ // stack (array). When a computed's fn() reads another dirty computed during
172
+ // evaluation, _evaluateComputed throws NEEDS_UPSTREAM. The outer loop catches
173
+ // this, adds the upstream to the stack, and processes from the bottom up.
174
+ // This converts O(N) call depth to O(1) per computed (just the outermost loop).
175
+
176
+ function _evaluateComputed(computedEffect) {
177
+ if (iterativeEvalStack !== null) {
178
+ // We're inside the outermost evaluation loop, and a computed's fn()
179
+ // is reading another dirty computed. Push it onto the stack and throw
180
+ // to abort the current fn() so the outer loop can process it first.
181
+ iterativeEvalStack.push(computedEffect);
182
+ throw NEEDS_UPSTREAM;
183
+ }
184
+
185
+ // Outermost call — enter the iterative evaluation loop.
186
+ // The stack grows as we discover dirty upstream computeds.
187
+ const stack = [computedEffect];
188
+ iterativeEvalStack = stack;
189
+
190
+ try {
191
+ while (stack.length > 0) {
192
+ const current = stack[stack.length - 1];
193
+
194
+ if (!current._isDirty || !current._isDirty()) {
195
+ // Already clean — pop and continue
196
+ stack.pop();
197
+ continue;
198
+ }
199
+
200
+ // Pre-scan known deps: if any are dirty computeds, push them onto
201
+ // the stack first (bottom-up). This avoids the O(N^2) worst case
202
+ // where throw/catch restarts from the top on each dirty upstream.
203
+ let pushedUpstream = false;
204
+ const deps = current.deps;
205
+ for (let i = 0; i < deps.length; i++) {
206
+ const depOwner = subSetOwner.get(deps[i]);
207
+ if (depOwner && depOwner._computed && depOwner._isDirty && depOwner._isDirty()) {
208
+ stack.push(depOwner);
209
+ pushedUpstream = true;
210
+ }
211
+ }
212
+ if (pushedUpstream) {
213
+ // Process dirty upstreams first before re-evaluating current
214
+ continue;
215
+ }
216
+
217
+ // All known deps are clean — evaluate. throw/catch is fallback
218
+ // for newly-discovered deps only.
219
+ try {
220
+ const prevDepsLen = current.deps.length;
221
+ _runEffect(current);
222
+ // Only recompute level when graph structure changes
223
+ if (current.deps.length !== prevDepsLen) {
224
+ _updateLevel(current);
225
+ }
226
+ stack.pop(); // Successfully evaluated
227
+ } catch (err) {
228
+ if (err === NEEDS_UPSTREAM) {
229
+ // A dirty upstream was discovered and pushed onto the stack.
230
+ // Re-mark this computed dirty since its fn() was aborted mid-execution.
231
+ current._markDirty();
232
+ // The upstream is now at stack[stack.length-1]. Loop continues.
233
+ } else {
234
+ throw err; // Re-throw real errors
235
+ }
236
+ }
237
+ }
238
+ } finally {
239
+ iterativeEvalStack = null;
240
+ }
241
+ }
242
+
243
+ // Update the topological level of a computed/effect based on its current dependencies.
244
+ function _updateLevel(e) {
245
+ let maxDepLevel = 0;
246
+ const deps = e.deps;
247
+ for (let i = 0; i < deps.length; i++) {
248
+ const owner = subSetOwner.get(deps[i]);
249
+ if (owner) {
250
+ const depLevel = owner._level;
251
+ if (depLevel > maxDepLevel) maxDepLevel = depLevel;
252
+ }
253
+ }
254
+ e._level = maxDepLevel + 1;
255
+ }
256
+
113
257
  // --- Effect ---
114
258
  // Runs a function, auto-tracking signal reads. Re-runs when deps change.
115
259
  // Returns a dispose function.
116
260
 
117
261
  export function effect(fn, opts) {
118
262
  const e = _createEffect(fn);
263
+ e._level = 1;
119
264
  // First run: skip cleanup (deps is empty), just run and track
120
265
  const prev = currentEffect;
121
266
  currentEffect = e;
@@ -125,6 +270,8 @@ export function effect(fn, opts) {
125
270
  } finally {
126
271
  currentEffect = prev;
127
272
  }
273
+ // Compute level after first run based on actual dependencies (cached).
274
+ _updateLevel(e);
128
275
  // Mark as stable after first run — subsequent re-runs skip cleanup/re-subscribe
129
276
  if (opts?.stable) e._stable = true;
130
277
  const dispose = () => _disposeEffect(e);
@@ -151,6 +298,8 @@ export function batch(fn) {
151
298
  // --- Internals ---
152
299
 
153
300
  function _createEffect(fn, lazy) {
301
+ // Minimal object shape — computed() adds extra properties after creation.
302
+ // Keeping the base object small helps V8 optimize for the common (effect) case.
154
303
  const e = {
155
304
  fn,
156
305
  deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
@@ -159,6 +308,11 @@ function _createEffect(fn, lazy) {
159
308
  disposed: false,
160
309
  _pending: false,
161
310
  _stable: false, // stable effects skip cleanup/re-subscribe on re-run
311
+ _level: 0, // topological depth: signals=0, computed/effects=max(deps)+1
312
+ _computed: false, // true for computed inner effects
313
+ _computedSubs: null, // reference to the computed's subscriber set
314
+ _isDirty: null, // function to check if computed is dirty (set by computed())
315
+ _markDirty: null, // function to mark computed dirty (set by computed())
162
316
  };
163
317
  if (__DEV__ && __devtools) __devtools.onEffectCreate(e);
164
318
  return e;
@@ -168,7 +322,6 @@ function _runEffect(e) {
168
322
  if (e.disposed) return;
169
323
 
170
324
  // Stable effect fast path: deps don't change, skip cleanup/re-subscribe.
171
- // Effect stays subscribed to its signals from the first run.
172
325
  if (e._stable) {
173
326
  if (e._cleanup) {
174
327
  try { e._cleanup(); } catch (err) {
@@ -209,6 +362,7 @@ function _runEffect(e) {
209
362
  e._cleanup = result;
210
363
  }
211
364
  } catch (err) {
365
+ if (err === NEEDS_UPSTREAM) throw err; // Iterative eval sentinel — not a real error
212
366
  if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
213
367
  throw err;
214
368
  } finally {
@@ -236,34 +390,107 @@ function cleanup(e) {
236
390
  deps.length = 0;
237
391
  }
238
392
 
393
+ // --- Notification ---
394
+ // Iterative notification to prevent stack overflow on deep computed chains.
395
+ // Uses a reusable queue to avoid per-call array allocation.
396
+ // When notify() encounters _onNotify callbacks (from computeds), those may
397
+ // call notify() recursively. The queue drains iteratively in the outermost call.
398
+
399
+ let notifyDepth = 0; // Tracks recursive notify depth
400
+ let notifyQueue = null; // Reusable queue, allocated on first recursive call
401
+ let notifyQueueLen = 0; // Length of the queue
402
+
239
403
  function notify(subs) {
240
- for (const e of subs) {
241
- if (e.disposed) continue;
242
- if (e._onNotify) {
243
- e._onNotify();
244
- } else if (batchDepth === 0 && e._stable) {
245
- // Inline execution for stable effects: skip queue + flush + _runEffect overhead.
246
- // Safe because stable effects have fixed deps (no re-subscribe needed).
247
- const prev = currentEffect;
248
- currentEffect = null;
249
- try {
250
- const result = e.fn();
251
- if (typeof result === 'function') {
252
- if (e._cleanup) try { e._cleanup(); } catch (err) {}
253
- e._cleanup = result;
404
+ // Fast path: no recursive notifications in progress — iterate directly.
405
+ // This avoids array allocation for the common case (signal → effects).
406
+ if (notifyDepth === 0) {
407
+ notifyDepth = 1;
408
+ try {
409
+ for (const e of subs) {
410
+ if (e.disposed) continue;
411
+ if (e._onNotify) {
412
+ // Computed subscriber: mark dirty and propagate.
413
+ // _onNotify may call notify() recursively — tracked by notifyDepth.
414
+ e._onNotify();
415
+ } else if (batchDepth === 0 && e._stable) {
416
+ // Inline execution for stable effects
417
+ const prev = currentEffect;
418
+ currentEffect = null;
419
+ try {
420
+ const result = e.fn();
421
+ if (typeof result === 'function') {
422
+ if (e._cleanup) try { e._cleanup(); } catch (err) {}
423
+ e._cleanup = result;
424
+ }
425
+ } catch (err) {
426
+ if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
427
+ if (__DEV__) console.warn('[what] Error in stable effect:', err);
428
+ } finally {
429
+ currentEffect = prev;
430
+ }
431
+ } else if (!e._pending) {
432
+ e._pending = true;
433
+ const level = e._level;
434
+ const len = pendingEffects.length;
435
+ if (len > 0 && pendingEffects[len - 1]._level > level) {
436
+ pendingNeedSort = true;
437
+ }
438
+ pendingEffects.push(e);
254
439
  }
255
- } catch (err) {
256
- if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
257
- if (__DEV__) console.warn('[what] Error in stable effect:', err);
258
- } finally {
259
- currentEffect = prev;
260
440
  }
261
- } else if (!e._pending) {
262
- e._pending = true;
263
- pendingEffects.push(e);
441
+ // Drain any queued subscriber sets from recursive notify calls
442
+ if (notifyQueueLen > 0) {
443
+ let qi = 0;
444
+ while (qi < notifyQueueLen) {
445
+ const queuedSubs = notifyQueue[qi];
446
+ notifyQueue[qi] = null; // Allow GC
447
+ qi++;
448
+ for (const e of queuedSubs) {
449
+ if (e.disposed) continue;
450
+ if (e._onNotify) {
451
+ e._onNotify();
452
+ } else if (batchDepth === 0 && e._stable) {
453
+ const prev = currentEffect;
454
+ currentEffect = null;
455
+ try {
456
+ const result = e.fn();
457
+ if (typeof result === 'function') {
458
+ if (e._cleanup) try { e._cleanup(); } catch (err) {}
459
+ e._cleanup = result;
460
+ }
461
+ } catch (err) {
462
+ if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
463
+ if (__DEV__) console.warn('[what] Error in stable effect:', err);
464
+ } finally {
465
+ currentEffect = prev;
466
+ }
467
+ } else if (!e._pending) {
468
+ e._pending = true;
469
+ const level = e._level;
470
+ const len = pendingEffects.length;
471
+ if (len > 0 && pendingEffects[len - 1]._level > level) {
472
+ pendingNeedSort = true;
473
+ }
474
+ pendingEffects.push(e);
475
+ }
476
+ }
477
+ }
478
+ notifyQueueLen = 0;
479
+ }
480
+ } finally {
481
+ notifyDepth = 0;
264
482
  }
483
+ if (batchDepth === 0 && pendingEffects.length > 0) scheduleMicrotask();
484
+ } else {
485
+ // Recursive call — queue the subscriber set for the outermost call to drain.
486
+ if (notifyQueue === null) notifyQueue = [];
487
+ if (notifyQueueLen >= notifyQueue.length) {
488
+ notifyQueue.push(subs);
489
+ } else {
490
+ notifyQueue[notifyQueueLen] = subs;
491
+ }
492
+ notifyQueueLen++;
265
493
  }
266
- if (batchDepth === 0 && pendingEffects.length > 0) scheduleMicrotask();
267
494
  }
268
495
 
269
496
  let microtaskScheduled = false;
@@ -277,41 +504,73 @@ function scheduleMicrotask() {
277
504
  }
278
505
  }
279
506
 
507
+ let isFlushing = false;
508
+
280
509
  function flush() {
281
- let iterations = 0;
282
- while (pendingEffects.length > 0 && iterations < 25) {
283
- const batch = pendingEffects;
284
- pendingEffects = [];
285
- for (let i = 0; i < batch.length; i++) {
286
- const e = batch[i];
287
- e._pending = false;
288
- if (!e.disposed && !e._onNotify) _runEffect(e);
510
+ // Re-entrancy guard: if flush() is called during an active flush (e.g., via
511
+ // flushSync() inside a component render or effect), skip to prevent infinite
512
+ // recursion. Pending effects will be picked up by the outer flush's while-loop.
513
+ if (isFlushing) return;
514
+ isFlushing = true;
515
+
516
+ try {
517
+ let iterations = 0;
518
+ while (pendingEffects.length > 0 && iterations < 25) {
519
+ const batch = pendingEffects;
520
+ pendingEffects = [];
521
+
522
+ // Topological sort: execute effects in level order (lowest first).
523
+ // Fast paths:
524
+ // 1. Single effect — no sort needed (most common case for microtask flush)
525
+ // 2. Already sorted — skip sort (common when effects added in level order)
526
+ // 3. Multiple effects at different levels — sort required
527
+ if (batch.length > 1 && pendingNeedSort) {
528
+ batch.sort((a, b) => a._level - b._level);
529
+ }
530
+ pendingNeedSort = false;
531
+
532
+ for (let i = 0; i < batch.length; i++) {
533
+ const e = batch[i];
534
+ e._pending = false;
535
+ if (!e.disposed && !e._onNotify) {
536
+ const prevDepsLen = e.deps.length;
537
+ _runEffect(e);
538
+ // Update level only if deps changed (graph structure change)
539
+ if (!e._computed && e.deps.length !== prevDepsLen) {
540
+ _updateLevel(e);
541
+ }
542
+ }
543
+ }
544
+ iterations++;
289
545
  }
290
- iterations++;
291
- }
292
- if (iterations >= 25) {
293
- if (__DEV__) {
294
- const remaining = pendingEffects.slice(0, 3);
295
- const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
296
- console.warn(
297
- `[what] Possible infinite effect loop detected (25 iterations). ` +
298
- `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
299
- `Use untrack() to read signals without subscribing. ` +
300
- `Looping effects: ${effectNames.join(', ')}`
301
- );
302
- } else {
303
- console.warn('[what] Possible infinite effect loop detected');
546
+ if (iterations >= 25) {
547
+ // Clear pending effects to prevent further damage
548
+ for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
549
+ pendingEffects.length = 0;
550
+
551
+ if (__DEV__) {
552
+ const remaining = pendingEffects.slice(0, 3);
553
+ const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
554
+ console.warn(
555
+ `[what] Possible infinite effect loop detected (25 iterations). ` +
556
+ `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
557
+ `Use untrack() to read signals without subscribing. ` +
558
+ `Looping effects: ${effectNames.join(', ')}`
559
+ );
560
+ } else {
561
+ console.warn('[what] Possible infinite effect loop detected');
562
+ }
304
563
  }
305
- for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
306
- pendingEffects.length = 0;
564
+ } finally {
565
+ isFlushing = false;
307
566
  }
308
567
  }
309
568
 
310
569
  // --- Memo ---
311
570
  // Eager computed that only propagates when the value actually changes.
312
- // Reads deps eagerly (unlike lazy computed), but skips notifying subscribers
313
- // when the recomputed value is the same. Critical for patterns like:
314
- // memo(() => selected() === item().id) — 1000 memos, only 2 change
571
+ // Fix: Instead of calling notify(subs) inline (which bypasses topological sort
572
+ // and causes diamond-dependency glitches), push memo subscribers into
573
+ // pendingEffects and let them go through the sorted flush() path.
315
574
  export function memo(fn) {
316
575
  let value;
317
576
  const subs = new Set();
@@ -320,11 +579,33 @@ export function memo(fn) {
320
579
  const next = fn();
321
580
  if (!Object.is(value, next)) {
322
581
  value = next;
323
- notify(subs);
582
+ // Push subscribers into pendingEffects for topological flush
583
+ // instead of inline notify() which can cause diamond glitches
584
+ for (const sub of subs) {
585
+ if (sub.disposed) continue;
586
+ if (sub._onNotify) {
587
+ // Computed subscriber: mark dirty and propagate
588
+ sub._onNotify();
589
+ } else if (!sub._pending) {
590
+ sub._pending = true;
591
+ const level = sub._level;
592
+ const len = pendingEffects.length;
593
+ if (len > 0 && pendingEffects[len - 1]._level > level) {
594
+ pendingNeedSort = true;
595
+ }
596
+ pendingEffects.push(sub);
597
+ }
598
+ }
324
599
  }
325
600
  });
326
601
 
602
+ e._level = 1;
603
+
327
604
  _runEffect(e);
605
+ _updateLevel(e);
606
+
607
+ // Register subscriber set owner for level tracking
608
+ subSetOwner.set(subs, e);
328
609
 
329
610
  // Register with current root
330
611
  if (currentRoot) {
@@ -346,7 +627,31 @@ export function memo(fn) {
346
627
 
347
628
  // --- flushSync ---
348
629
  // Force all pending effects to run synchronously. Use sparingly.
630
+ // Calling during render or effect execution is a no-op (prevents infinite loops).
349
631
  export function flushSync() {
632
+ if (isFlushing) {
633
+ // Re-entrant call — silently skip (Solid approach).
634
+ // This prevents infinite loops when flushSync() is called during component
635
+ // render or effect execution. Pending effects will be picked up by the
636
+ // outer flush's while-loop.
637
+ if (__DEV__) {
638
+ console.warn(
639
+ '[what] flushSync() called during an active flush (e.g., inside a component render or effect). ' +
640
+ 'This is a no-op to prevent infinite loops. Move flushSync() to an event handler or onMount callback.'
641
+ );
642
+ }
643
+ return;
644
+ }
645
+ if (currentEffect) {
646
+ // Called inside an effect/render — skip with warning
647
+ if (__DEV__) {
648
+ console.warn(
649
+ '[what] flushSync() called during effect execution. ' +
650
+ 'This is a no-op to prevent infinite loops. Move flushSync() to an event handler or onMount callback.'
651
+ );
652
+ }
653
+ return;
654
+ }
350
655
  microtaskScheduled = false;
351
656
  flush();
352
657
  }
@@ -363,22 +668,103 @@ export function untrack(fn) {
363
668
  }
364
669
  }
365
670
 
671
+ // --- getOwner / runWithOwner ---
672
+ // Expose ownership context for advanced use cases (e.g., async operations
673
+ // that need to register disposals with the correct owner).
674
+
675
+ export function getOwner() {
676
+ return currentOwner;
677
+ }
678
+
679
+ export function runWithOwner(owner, fn) {
680
+ const prev = currentOwner;
681
+ const prevRoot = currentRoot;
682
+ currentOwner = owner;
683
+ currentRoot = owner;
684
+ try {
685
+ return fn();
686
+ } finally {
687
+ currentOwner = prev;
688
+ currentRoot = prevRoot;
689
+ }
690
+ }
691
+
366
692
  // --- createRoot ---
367
- // Isolated reactive scope. All effects created inside are tracked and disposed together.
368
- // Essential for per-item cleanup in reactive lists.
693
+ // Isolated reactive scope with ownership tree.
694
+ // All effects created inside are tracked and disposed together.
695
+ // Child createRoot scopes register with parent owner — disposing parent
696
+ // automatically disposes all children (prevents orphaned subscriptions).
369
697
  export function createRoot(fn) {
370
698
  const prevRoot = currentRoot;
371
- const root = { disposals: [], owner: currentRoot };
699
+ const prevOwner = currentOwner;
700
+ const root = {
701
+ disposals: [],
702
+ owner: currentOwner, // parent owner for ownership tree
703
+ children: [], // child roots (ownership tree)
704
+ _disposed: false,
705
+ };
706
+
707
+ // Register this root as a child of the parent owner
708
+ if (currentOwner) {
709
+ currentOwner.children.push(root);
710
+ }
711
+
372
712
  currentRoot = root;
713
+ currentOwner = root;
714
+
373
715
  try {
374
716
  const dispose = () => {
717
+ if (root._disposed) return;
718
+ root._disposed = true;
719
+
720
+ // Dispose children first (depth-first, reverse order)
721
+ for (let i = root.children.length - 1; i >= 0; i--) {
722
+ _disposeRoot(root.children[i]);
723
+ }
724
+ root.children.length = 0;
725
+
726
+ // Dispose own effects (reverse order for LIFO cleanup)
375
727
  for (let i = root.disposals.length - 1; i >= 0; i--) {
376
728
  root.disposals[i]();
377
729
  }
378
730
  root.disposals.length = 0;
731
+
732
+ // Remove from parent's children list
733
+ if (root.owner) {
734
+ const idx = root.owner.children.indexOf(root);
735
+ if (idx >= 0) root.owner.children.splice(idx, 1);
736
+ }
379
737
  };
380
738
  return fn(dispose);
381
739
  } finally {
382
740
  currentRoot = prevRoot;
741
+ currentOwner = prevOwner;
742
+ }
743
+ }
744
+
745
+ // Internal: dispose a root and all its children
746
+ function _disposeRoot(root) {
747
+ if (root._disposed) return;
748
+ root._disposed = true;
749
+
750
+ // Dispose children first
751
+ for (let i = root.children.length - 1; i >= 0; i--) {
752
+ _disposeRoot(root.children[i]);
753
+ }
754
+ root.children.length = 0;
755
+
756
+ // Dispose own effects
757
+ for (let i = root.disposals.length - 1; i >= 0; i--) {
758
+ root.disposals[i]();
759
+ }
760
+ root.disposals.length = 0;
761
+ }
762
+
763
+ // --- onCleanup ---
764
+ // Register a cleanup function with the current owner/root.
765
+ // Runs when the owner is disposed.
766
+ export function onCleanup(fn) {
767
+ if (currentRoot) {
768
+ currentRoot.disposals.push(fn);
383
769
  }
384
770
  }