what-core 0.6.2 → 0.8.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
@@ -29,9 +29,9 @@ let batchDepth = 0;
29
29
  let pendingEffects = [];
30
30
  let pendingNeedSort = false; // Track whether pendingEffects actually needs sorting
31
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();
32
+ // Instead of a WeakMap from subscriber Set → owning computed's inner effect,
33
+ // we store the owner directly on the Set as ._owner (20x faster than WeakMap.get).
34
+ // Signal subscriber Sets have ._owner = undefined (signals are level 0).
35
35
 
36
36
  // --- Iterative Computed Evaluation State ---
37
37
  // Uses a throw/catch trampoline to convert recursive computed evaluation
@@ -43,22 +43,28 @@ let iterativeEvalStack = null; // array when inside evaluation loop, null other
43
43
  // --- Signal ---
44
44
  // A reactive value. Reading inside an effect auto-tracks the dependency.
45
45
  // Writing triggers only the effects that depend on this signal.
46
+ //
47
+ // Performance: signal read is the hottest path in any signal-based framework.
48
+ // Key optimizations:
49
+ // - No rest args (...args) — uses arguments.length for zero-alloc read path
50
+ // - Subscriber tracking uses lastTracked to skip redundant Set.add/Array.push
51
+ // when the same signal is read multiple times in one effect (common pattern)
52
+ // - Write path uses === first (fast for primitives), falls back to Object.is
53
+ // only for NaN detection
54
+ // - subs.size check avoids notify() call when no subscribers
46
55
 
47
56
  export function signal(initial, debugName) {
48
57
  let value = initial;
49
58
  const subs = new Set();
50
-
51
- // Unified getter/setter: sig() reads, sig(newVal) writes
52
- function sig(...args) {
53
- if (args.length === 0) {
54
- // Read
55
- if (currentEffect) {
56
- subs.add(currentEffect);
57
- currentEffect.deps.push(subs);
58
- }
59
- return value;
60
- }
61
- // Write
59
+ // Track the last effect that subscribed — skip redundant tracking when the
60
+ // same effect reads this signal multiple times (common in template bindings).
61
+ // lastTrackedEpoch tracks the effect's cleanup epoch to detect stale caches.
62
+ let lastTracked = null;
63
+ let lastTrackedEpoch = 0;
64
+
65
+ // Shared write logic — inlined via _sigWrite closure to avoid per-call overhead
66
+ // while keeping the sig() function body minimal for V8 optimization.
67
+ function _sigWrite(next) {
62
68
  if (__DEV__ && insideComputed) {
63
69
  console.warn(
64
70
  '[what] Signal.set() called inside a computed function. ' +
@@ -66,27 +72,42 @@ export function signal(initial, debugName) {
66
72
  (debugName ? ` (signal: ${debugName})` : '')
67
73
  );
68
74
  }
69
- const nextVal = typeof args[0] === 'function' ? args[0](value) : args[0];
70
- if (Object.is(value, nextVal)) return;
75
+ const nextVal = typeof next === 'function' ? next(value) : next;
76
+ // Fast equality: === handles all primitives except NaN.
77
+ // Only fall through for the NaN !== NaN case.
78
+ if (value === nextVal || (value !== value && nextVal !== nextVal)) return;
71
79
  value = nextVal;
80
+ // Invalidate lastTracked since value changed — any effect that reads
81
+ // this signal during re-run needs to re-track.
82
+ lastTracked = null;
72
83
  if (__DEV__ && __devtools) __devtools.onSignalUpdate(sig);
73
84
  if (subs.size > 0) notify(subs);
74
85
  }
75
86
 
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
- );
87
+ // Unified getter/setter: sig() reads, sig(newVal) writes
88
+ // Using arguments.length instead of rest args avoids array allocation on read
89
+ function sig(newVal) {
90
+ if (arguments.length === 0) {
91
+ // Read hot path, keep minimal
92
+ const ce = currentEffect;
93
+ if (ce !== null) {
94
+ // Only track if this signal isn't already in the effect's deps.
95
+ // lastTracked is a fast cache for the common case (single effect reading
96
+ // this signal). It's reset to null on write and on cleanup epoch change.
97
+ if (ce !== lastTracked || ce._epoch !== lastTrackedEpoch) {
98
+ lastTracked = ce;
99
+ lastTrackedEpoch = ce._epoch;
100
+ subs.add(ce);
101
+ ce.deps.push(subs);
102
+ }
103
+ }
104
+ return value;
83
105
  }
84
- const nextVal = typeof next === 'function' ? next(value) : next;
85
- if (Object.is(value, nextVal)) return;
86
- value = nextVal;
87
- if (__DEV__ && __devtools) __devtools.onSignalUpdate(sig);
88
- if (subs.size > 0) notify(subs);
89
- };
106
+ // Write via sig(newVal)
107
+ _sigWrite(newVal);
108
+ }
109
+
110
+ sig.set = _sigWrite;
90
111
 
91
112
  sig.peek = () => value;
92
113
 
@@ -113,6 +134,8 @@ export function signal(initial, debugName) {
113
134
  export function computed(fn) {
114
135
  let value, dirty = true;
115
136
  const subs = new Set();
137
+ let lastTracked = null;
138
+ let lastTrackedEpoch = 0;
116
139
 
117
140
  const inner = _createEffect(() => {
118
141
  const prevInsideComputed = insideComputed;
@@ -131,16 +154,21 @@ export function computed(fn) {
131
154
  inner._computedSubs = subs;
132
155
 
133
156
  // Register this subscriber set as owned by this computed
134
- subSetOwner.set(subs, inner);
157
+ subs._owner = inner;
135
158
 
136
159
  // Store markDirty/isDirty closures on the inner effect for iterative eval
137
160
  inner._markDirty = () => { dirty = true; };
138
161
  inner._isDirty = () => dirty;
139
162
 
140
163
  function read() {
141
- if (currentEffect) {
142
- subs.add(currentEffect);
143
- currentEffect.deps.push(subs);
164
+ const ce = currentEffect;
165
+ if (ce !== null) {
166
+ if (ce !== lastTracked || ce._epoch !== lastTrackedEpoch) {
167
+ lastTracked = ce;
168
+ lastTrackedEpoch = ce._epoch;
169
+ subs.add(ce);
170
+ ce.deps.push(subs);
171
+ }
144
172
  }
145
173
  if (dirty) _evaluateComputed(inner);
146
174
  return value;
@@ -149,6 +177,7 @@ export function computed(fn) {
149
177
  // When a dependency changes, mark dirty AND propagate to our subscribers.
150
178
  inner._onNotify = () => {
151
179
  dirty = true;
180
+ lastTracked = null; // Invalidate tracking cache on value change
152
181
  if (subs.size > 0) notify(subs);
153
182
  };
154
183
 
@@ -203,7 +232,7 @@ function _evaluateComputed(computedEffect) {
203
232
  let pushedUpstream = false;
204
233
  const deps = current.deps;
205
234
  for (let i = 0; i < deps.length; i++) {
206
- const depOwner = subSetOwner.get(deps[i]);
235
+ const depOwner = deps[i]._owner;
207
236
  if (depOwner && depOwner._computed && depOwner._isDirty && depOwner._isDirty()) {
208
237
  stack.push(depOwner);
209
238
  pushedUpstream = true;
@@ -245,7 +274,7 @@ function _updateLevel(e) {
245
274
  let maxDepLevel = 0;
246
275
  const deps = e.deps;
247
276
  for (let i = 0; i < deps.length; i++) {
248
- const owner = subSetOwner.get(deps[i]);
277
+ const owner = deps[i]._owner;
249
278
  if (owner) {
250
279
  const depLevel = owner._level;
251
280
  if (depLevel > maxDepLevel) maxDepLevel = depLevel;
@@ -299,7 +328,9 @@ export function batch(fn) {
299
328
 
300
329
  function _createEffect(fn, lazy) {
301
330
  // Minimal object shape — computed() adds extra properties after creation.
302
- // Keeping the base object small helps V8 optimize for the common (effect) case.
331
+ // IMPORTANT: V8 optimizes objects with a consistent "hidden class" (shape).
332
+ // All properties must be declared upfront even if null — adding properties
333
+ // later causes shape transitions which deoptimize property access globally.
303
334
  const e = {
304
335
  fn,
305
336
  deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
@@ -313,6 +344,8 @@ function _createEffect(fn, lazy) {
313
344
  _computedSubs: null, // reference to the computed's subscriber set
314
345
  _isDirty: null, // function to check if computed is dirty (set by computed())
315
346
  _markDirty: null, // function to mark computed dirty (set by computed())
347
+ _cleanup: null, // cleanup function returned by effect fn (declared upfront for shape)
348
+ _epoch: 0, // incremented on cleanup — used by signal lastTracked cache
316
349
  };
317
350
  if (__DEV__ && __devtools) __devtools.onEffectCreate(e);
318
351
  return e;
@@ -322,6 +355,9 @@ function _runEffect(e) {
322
355
  if (e.disposed) return;
323
356
 
324
357
  // Stable effect fast path: deps don't change, skip cleanup/re-subscribe.
358
+ // This is critical for performance: effects like `() => el.className = sig() ? 'a' : ''`
359
+ // always read the same signal(s). After auto-promotion, re-runs skip the O(deps)
360
+ // cleanup + re-subscribe cycle entirely.
325
361
  if (e._stable) {
326
362
  if (e._cleanup) {
327
363
  try { e._cleanup(); } catch (err) {
@@ -344,11 +380,15 @@ function _runEffect(e) {
344
380
  return;
345
381
  }
346
382
 
383
+ // Save the single dep for auto-stable detection (safe: 1-dep effects
384
+ // have deterministic dep sets — no conditional reads possible).
385
+ const singleDep = e.deps.length === 1 ? e.deps[0] : null;
386
+
347
387
  cleanup(e);
348
388
  // Run effect cleanup from previous run
349
389
  if (e._cleanup) {
350
390
  try { e._cleanup(); } catch (err) {
351
- if (__devtools?.onError) __devtools.onError(err, { type: 'effect-cleanup', effect: e });
391
+ if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect-cleanup', effect: e });
352
392
  if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
353
393
  }
354
394
  e._cleanup = null;
@@ -363,11 +403,23 @@ function _runEffect(e) {
363
403
  }
364
404
  } catch (err) {
365
405
  if (err === NEEDS_UPSTREAM) throw err; // Iterative eval sentinel — not a real error
366
- if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
406
+ if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
367
407
  throw err;
368
408
  } finally {
369
409
  currentEffect = prev;
370
410
  }
411
+
412
+ // Auto-promote to stable: effects with exactly 1 dep that remains the same
413
+ // after re-run have a fixed dependency graph. Skip cleanup/re-subscribe
414
+ // on future re-runs. This is safe because a single-dep effect can't have
415
+ // conditional signal reads that change which signal is tracked.
416
+ // Guard: don't promote self-triggering effects (those that write to the signal
417
+ // they read, causing re-queuing). Check e._pending to detect this.
418
+ if (singleDep !== null && e.deps.length === 1 && e.deps[0] === singleDep
419
+ && !e._cleanup && !e._pending) {
420
+ e._stable = true;
421
+ }
422
+
371
423
  if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun(e);
372
424
  }
373
425
 
@@ -388,6 +440,9 @@ function cleanup(e) {
388
440
  const deps = e.deps;
389
441
  for (let i = 0; i < deps.length; i++) deps[i].delete(e);
390
442
  deps.length = 0;
443
+ // Increment epoch so signals' lastTracked cache is invalidated.
444
+ // This ensures a signal will re-track this effect after cleanup.
445
+ e._epoch++;
391
446
  }
392
447
 
393
448
  // --- Notification ---
@@ -400,6 +455,43 @@ let notifyDepth = 0; // Tracks recursive notify depth
400
455
  let notifyQueue = null; // Reusable queue, allocated on first recursive call
401
456
  let notifyQueueLen = 0; // Length of the queue
402
457
 
458
+ // Process a single subscriber during notification.
459
+ // Extracted to avoid code duplication between outer and queue drain paths.
460
+ function _processSubscriber(e) {
461
+ if (e.disposed) return;
462
+ if (e._onNotify) {
463
+ // Computed subscriber: mark dirty and propagate.
464
+ // _onNotify may call notify() recursively — tracked by notifyDepth.
465
+ e._onNotify();
466
+ } else if (!e._pending) {
467
+ if (batchDepth === 0 && e._stable) {
468
+ // Inline execution for stable effects — no pending queue needed
469
+ const prev = currentEffect;
470
+ currentEffect = null;
471
+ try {
472
+ const result = e.fn();
473
+ if (typeof result === 'function') {
474
+ if (e._cleanup) try { e._cleanup(); } catch (err) { /* ignore */ }
475
+ e._cleanup = result;
476
+ }
477
+ } catch (err) {
478
+ if (__DEV__ && __devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
479
+ if (__DEV__) console.warn('[what] Error in stable effect:', err);
480
+ } finally {
481
+ currentEffect = prev;
482
+ }
483
+ } else {
484
+ e._pending = true;
485
+ const level = e._level;
486
+ const len = pendingEffects.length;
487
+ if (len > 0 && pendingEffects[len - 1]._level > level) {
488
+ pendingNeedSort = true;
489
+ }
490
+ pendingEffects.push(e);
491
+ }
492
+ }
493
+ }
494
+
403
495
  function notify(subs) {
404
496
  // Fast path: no recursive notifications in progress — iterate directly.
405
497
  // This avoids array allocation for the common case (signal → effects).
@@ -407,36 +499,7 @@ function notify(subs) {
407
499
  notifyDepth = 1;
408
500
  try {
409
501
  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);
439
- }
502
+ _processSubscriber(e);
440
503
  }
441
504
  // Drain any queued subscriber sets from recursive notify calls
442
505
  if (notifyQueueLen > 0) {
@@ -446,33 +509,7 @@ function notify(subs) {
446
509
  notifyQueue[qi] = null; // Allow GC
447
510
  qi++;
448
511
  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
- }
512
+ _processSubscriber(e);
476
513
  }
477
514
  }
478
515
  notifyQueueLen = 0;
@@ -544,10 +581,6 @@ function flush() {
544
581
  iterations++;
545
582
  }
546
583
  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
584
  if (__DEV__) {
552
585
  const remaining = pendingEffects.slice(0, 3);
553
586
  const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
@@ -560,6 +593,9 @@ function flush() {
560
593
  } else {
561
594
  console.warn('[what] Possible infinite effect loop detected');
562
595
  }
596
+ // Clear pending effects AFTER capturing debug info
597
+ for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
598
+ pendingEffects.length = 0;
563
599
  }
564
600
  } finally {
565
601
  isFlushing = false;
@@ -605,7 +641,7 @@ export function memo(fn) {
605
641
  _updateLevel(e);
606
642
 
607
643
  // Register subscriber set owner for level tracking
608
- subSetOwner.set(subs, e);
644
+ subs._owner = e;
609
645
 
610
646
  // Register with current root
611
647
  if (currentRoot) {
@@ -760,6 +796,45 @@ function _disposeRoot(root) {
760
796
  root.disposals.length = 0;
761
797
  }
762
798
 
799
+ // --- _createItemScope ---
800
+ // Lightweight reactive scope for list items. Unlike createRoot, this does NOT
801
+ // register with the parent ownership tree (saves ~40% allocation overhead).
802
+ // Used by mapArray where disposal is managed explicitly by the list reconciler.
803
+ export function _createItemScope(fn) {
804
+ const prevRoot = currentRoot;
805
+ const prevOwner = currentOwner;
806
+ const scope = {
807
+ disposals: [],
808
+ owner: null, // No parent registration
809
+ children: [], // Kept for compat with effects that create sub-roots
810
+ _disposed: false,
811
+ };
812
+
813
+ currentRoot = scope;
814
+ currentOwner = scope;
815
+
816
+ try {
817
+ const dispose = () => {
818
+ if (scope._disposed) return;
819
+ scope._disposed = true;
820
+ // Dispose children
821
+ for (let i = scope.children.length - 1; i >= 0; i--) {
822
+ _disposeRoot(scope.children[i]);
823
+ }
824
+ scope.children.length = 0;
825
+ // Dispose own effects
826
+ for (let i = scope.disposals.length - 1; i >= 0; i--) {
827
+ scope.disposals[i]();
828
+ }
829
+ scope.disposals.length = 0;
830
+ };
831
+ return fn(dispose);
832
+ } finally {
833
+ currentRoot = prevRoot;
834
+ currentOwner = prevOwner;
835
+ }
836
+ }
837
+
763
838
  // --- onCleanup ---
764
839
  // Register a cleanup function with the current owner/root.
765
840
  // Runs when the owner is disposed.