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/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 +5919 -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 +1502 -273
- 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 +1204 -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 +225 -745
- 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 +389 -41
- package/src/render.js +445 -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;
|
|
@@ -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)
|
|
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
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
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
|
-
|
|
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
|
|
368
|
-
//
|
|
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
|
|
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
|
}
|