what-core 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -282,10 +509,28 @@ function flush() {
282
509
  while (pendingEffects.length > 0 && iterations < 25) {
283
510
  const batch = pendingEffects;
284
511
  pendingEffects = [];
512
+
513
+ // Topological sort: execute effects in level order (lowest first).
514
+ // Fast paths:
515
+ // 1. Single effect — no sort needed (most common case for microtask flush)
516
+ // 2. Already sorted — skip sort (common when effects added in level order)
517
+ // 3. Multiple effects at different levels — sort required
518
+ if (batch.length > 1 && pendingNeedSort) {
519
+ batch.sort((a, b) => a._level - b._level);
520
+ }
521
+ pendingNeedSort = false;
522
+
285
523
  for (let i = 0; i < batch.length; i++) {
286
524
  const e = batch[i];
287
525
  e._pending = false;
288
- if (!e.disposed && !e._onNotify) _runEffect(e);
526
+ if (!e.disposed && !e._onNotify) {
527
+ const prevDepsLen = e.deps.length;
528
+ _runEffect(e);
529
+ // Update level only if deps changed (graph structure change)
530
+ if (!e._computed && e.deps.length !== prevDepsLen) {
531
+ _updateLevel(e);
532
+ }
533
+ }
289
534
  }
290
535
  iterations++;
291
536
  }
@@ -309,9 +554,9 @@ function flush() {
309
554
 
310
555
  // --- Memo ---
311
556
  // 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
557
+ // Fix: Instead of calling notify(subs) inline (which bypasses topological sort
558
+ // and causes diamond-dependency glitches), push memo subscribers into
559
+ // pendingEffects and let them go through the sorted flush() path.
315
560
  export function memo(fn) {
316
561
  let value;
317
562
  const subs = new Set();
@@ -320,11 +565,33 @@ export function memo(fn) {
320
565
  const next = fn();
321
566
  if (!Object.is(value, next)) {
322
567
  value = next;
323
- notify(subs);
568
+ // Push subscribers into pendingEffects for topological flush
569
+ // instead of inline notify() which can cause diamond glitches
570
+ for (const sub of subs) {
571
+ if (sub.disposed) continue;
572
+ if (sub._onNotify) {
573
+ // Computed subscriber: mark dirty and propagate
574
+ sub._onNotify();
575
+ } else if (!sub._pending) {
576
+ sub._pending = true;
577
+ const level = sub._level;
578
+ const len = pendingEffects.length;
579
+ if (len > 0 && pendingEffects[len - 1]._level > level) {
580
+ pendingNeedSort = true;
581
+ }
582
+ pendingEffects.push(sub);
583
+ }
584
+ }
324
585
  }
325
586
  });
326
587
 
588
+ e._level = 1;
589
+
327
590
  _runEffect(e);
591
+ _updateLevel(e);
592
+
593
+ // Register subscriber set owner for level tracking
594
+ subSetOwner.set(subs, e);
328
595
 
329
596
  // Register with current root
330
597
  if (currentRoot) {
@@ -363,22 +630,103 @@ export function untrack(fn) {
363
630
  }
364
631
  }
365
632
 
633
+ // --- getOwner / runWithOwner ---
634
+ // Expose ownership context for advanced use cases (e.g., async operations
635
+ // that need to register disposals with the correct owner).
636
+
637
+ export function getOwner() {
638
+ return currentOwner;
639
+ }
640
+
641
+ export function runWithOwner(owner, fn) {
642
+ const prev = currentOwner;
643
+ const prevRoot = currentRoot;
644
+ currentOwner = owner;
645
+ currentRoot = owner;
646
+ try {
647
+ return fn();
648
+ } finally {
649
+ currentOwner = prev;
650
+ currentRoot = prevRoot;
651
+ }
652
+ }
653
+
366
654
  // --- createRoot ---
367
- // Isolated reactive scope. All effects created inside are tracked and disposed together.
368
- // Essential for per-item cleanup in reactive lists.
655
+ // Isolated reactive scope with ownership tree.
656
+ // All effects created inside are tracked and disposed together.
657
+ // Child createRoot scopes register with parent owner — disposing parent
658
+ // automatically disposes all children (prevents orphaned subscriptions).
369
659
  export function createRoot(fn) {
370
660
  const prevRoot = currentRoot;
371
- const root = { disposals: [], owner: currentRoot };
661
+ const prevOwner = currentOwner;
662
+ const root = {
663
+ disposals: [],
664
+ owner: currentOwner, // parent owner for ownership tree
665
+ children: [], // child roots (ownership tree)
666
+ _disposed: false,
667
+ };
668
+
669
+ // Register this root as a child of the parent owner
670
+ if (currentOwner) {
671
+ currentOwner.children.push(root);
672
+ }
673
+
372
674
  currentRoot = root;
675
+ currentOwner = root;
676
+
373
677
  try {
374
678
  const dispose = () => {
679
+ if (root._disposed) return;
680
+ root._disposed = true;
681
+
682
+ // Dispose children first (depth-first, reverse order)
683
+ for (let i = root.children.length - 1; i >= 0; i--) {
684
+ _disposeRoot(root.children[i]);
685
+ }
686
+ root.children.length = 0;
687
+
688
+ // Dispose own effects (reverse order for LIFO cleanup)
375
689
  for (let i = root.disposals.length - 1; i >= 0; i--) {
376
690
  root.disposals[i]();
377
691
  }
378
692
  root.disposals.length = 0;
693
+
694
+ // Remove from parent's children list
695
+ if (root.owner) {
696
+ const idx = root.owner.children.indexOf(root);
697
+ if (idx >= 0) root.owner.children.splice(idx, 1);
698
+ }
379
699
  };
380
700
  return fn(dispose);
381
701
  } finally {
382
702
  currentRoot = prevRoot;
703
+ currentOwner = prevOwner;
704
+ }
705
+ }
706
+
707
+ // Internal: dispose a root and all its children
708
+ function _disposeRoot(root) {
709
+ if (root._disposed) return;
710
+ root._disposed = true;
711
+
712
+ // Dispose children first
713
+ for (let i = root.children.length - 1; i >= 0; i--) {
714
+ _disposeRoot(root.children[i]);
715
+ }
716
+ root.children.length = 0;
717
+
718
+ // Dispose own effects
719
+ for (let i = root.disposals.length - 1; i >= 0; i--) {
720
+ root.disposals[i]();
721
+ }
722
+ root.disposals.length = 0;
723
+ }
724
+
725
+ // --- onCleanup ---
726
+ // Register a cleanup function with the current owner/root.
727
+ // Runs when the owner is disposed.
728
+ export function onCleanup(fn) {
729
+ if (currentRoot) {
730
+ currentRoot.disposals.push(fn);
383
731
  }
384
732
  }