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/README.md +8 -6
- package/dist/components.js +1 -1
- package/dist/dom.js +127 -451
- package/dist/h.js +1 -1
- package/dist/hooks.js +4 -0
- package/dist/index.js +5983 -123
- package/dist/index.js.map +7 -0
- package/dist/index.min.js +123 -0
- package/dist/index.min.js.map +7 -0
- package/dist/jsx-dev-runtime.js +51 -0
- package/dist/jsx-dev-runtime.js.map +7 -0
- package/dist/jsx-dev-runtime.min.js +2 -0
- package/dist/jsx-dev-runtime.min.js.map +7 -0
- package/dist/jsx-runtime.js +49 -0
- package/dist/jsx-runtime.js.map +7 -0
- package/dist/jsx-runtime.min.js +2 -0
- package/dist/jsx-runtime.min.js.map +7 -0
- package/dist/reactive.js +175 -11
- package/dist/render.js +1549 -272
- package/dist/render.js.map +7 -0
- package/dist/render.min.js +2 -0
- package/dist/render.min.js.map +7 -0
- package/dist/testing.js +1257 -144
- package/dist/testing.js.map +7 -0
- package/dist/testing.min.js +2 -0
- package/dist/testing.min.js.map +7 -0
- package/dist/what.js +3 -2
- package/package.json +9 -4
- package/src/agent-context.js +126 -0
- package/src/components.js +10 -34
- package/src/dom.js +269 -749
- package/src/errors.js +253 -0
- package/src/guardrails.js +224 -0
- package/src/h.js +3 -3
- package/src/hooks.js +121 -52
- package/src/index.js +38 -4
- package/src/reactive.js +450 -64
- package/src/render.js +462 -14
- package/src/testing.js +169 -1
- package/src/warnings.js +110 -0
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
|
-
|
|
84
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
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
|
|
368
|
-
//
|
|
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
|
|
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
|
}
|