what-core 0.3.0 → 0.4.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/dist/a11y.js +22 -7
- package/dist/animation.js +9 -1
- package/dist/components.js +61 -23
- package/dist/data.js +253 -59
- package/dist/dom.js +196 -24
- package/dist/form.js +112 -44
- package/dist/helpers.js +73 -10
- package/dist/hooks.js +63 -22
- package/dist/index.js +6 -2
- package/dist/reactive.js +189 -29
- package/dist/render.js +716 -0
- package/dist/scheduler.js +10 -5
- package/dist/store.js +18 -8
- package/package.json +10 -1
- package/src/a11y.js +22 -7
- package/src/animation.js +9 -1
- package/src/components.js +61 -23
- package/src/data.js +253 -59
- package/src/dom.js +222 -24
- package/src/form.js +112 -44
- package/src/h.js +3 -0
- package/src/helpers.js +73 -10
- package/src/hooks.js +63 -22
- package/src/index.js +6 -2
- package/src/jsx-dev-runtime.js +19 -0
- package/src/jsx-runtime.js +21 -0
- package/src/reactive.js +208 -39
- package/src/render.js +716 -0
- package/src/scheduler.js +10 -5
- package/src/store.js +18 -8
package/dist/helpers.js
CHANGED
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
// What Framework - Helpers & Utilities
|
|
2
2
|
// Commonly needed patterns, zero overhead.
|
|
3
3
|
|
|
4
|
-
import { signal, effect, computed, batch } from './reactive.js';
|
|
4
|
+
import { signal, effect, computed, batch, __DEV__ } from './reactive.js';
|
|
5
5
|
|
|
6
|
-
// --- show(condition, vnode) ---
|
|
6
|
+
// --- show(condition, vnode) --- [DEPRECATED: use <Show> component instead]
|
|
7
7
|
// Conditional rendering. More readable than ternary.
|
|
8
|
+
let _showWarned = false;
|
|
8
9
|
export function show(condition, vnode, fallback = null) {
|
|
10
|
+
if (!_showWarned) {
|
|
11
|
+
_showWarned = true;
|
|
12
|
+
console.warn('[what] show() is deprecated. Use the <Show> component or ternary expressions instead.');
|
|
13
|
+
}
|
|
9
14
|
return condition ? vnode : fallback;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
// --- each(list, fn) ---
|
|
17
|
+
// --- each(list, fn) --- [DEPRECATED: use <For> component or .map() instead]
|
|
13
18
|
// Keyed list rendering. Optimized for reconciliation.
|
|
19
|
+
let _eachWarned = false;
|
|
14
20
|
export function each(list, fn, keyFn) {
|
|
21
|
+
if (!_eachWarned) {
|
|
22
|
+
_eachWarned = true;
|
|
23
|
+
console.warn('[what] each() is deprecated. Use the <For> component or Array.map() instead.');
|
|
24
|
+
}
|
|
15
25
|
if (!list || list.length === 0) return [];
|
|
16
26
|
return list.map((item, index) => {
|
|
17
27
|
const vnode = fn(item, index);
|
|
@@ -76,18 +86,31 @@ export function throttle(fn, ms) {
|
|
|
76
86
|
};
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
// Component context ref — injected by dom.js to avoid circular imports
|
|
90
|
+
let _getCurrentComponentRef = null;
|
|
91
|
+
export function _setComponentRef(fn) { _getCurrentComponentRef = fn; }
|
|
92
|
+
|
|
79
93
|
// --- useMediaQuery ---
|
|
80
|
-
// Reactive media query. Returns a signal.
|
|
94
|
+
// Reactive media query. Returns a signal. Cleans up listener on component unmount.
|
|
81
95
|
export function useMediaQuery(query) {
|
|
82
96
|
if (typeof window === 'undefined') return signal(false);
|
|
83
97
|
const mq = window.matchMedia(query);
|
|
84
98
|
const s = signal(mq.matches);
|
|
85
|
-
|
|
99
|
+
const handler = (e) => s.set(e.matches);
|
|
100
|
+
mq.addEventListener('change', handler);
|
|
101
|
+
|
|
102
|
+
// Register cleanup if inside a component context
|
|
103
|
+
const ctx = _getCurrentComponentRef?.();
|
|
104
|
+
if (ctx) {
|
|
105
|
+
ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
|
|
106
|
+
ctx._cleanupCallbacks.push(() => mq.removeEventListener('change', handler));
|
|
107
|
+
}
|
|
108
|
+
|
|
86
109
|
return s;
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
// --- useLocalStorage ---
|
|
90
|
-
// Signal synced with localStorage.
|
|
113
|
+
// Signal synced with localStorage. Cleans up listeners on component unmount.
|
|
91
114
|
export function useLocalStorage(key, initial) {
|
|
92
115
|
let stored;
|
|
93
116
|
try {
|
|
@@ -100,18 +123,34 @@ export function useLocalStorage(key, initial) {
|
|
|
100
123
|
const s = signal(stored);
|
|
101
124
|
|
|
102
125
|
// Sync to localStorage on changes
|
|
103
|
-
effect(() => {
|
|
126
|
+
const dispose = effect(() => {
|
|
104
127
|
try {
|
|
105
128
|
localStorage.setItem(key, JSON.stringify(s()));
|
|
106
|
-
} catch {
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (__DEV__) console.warn('[what] localStorage write failed (quota exceeded?):', e);
|
|
131
|
+
}
|
|
107
132
|
});
|
|
108
133
|
|
|
109
134
|
// Listen for changes from other tabs
|
|
135
|
+
let storageHandler = null;
|
|
110
136
|
if (typeof window !== 'undefined') {
|
|
111
|
-
|
|
137
|
+
storageHandler = (e) => {
|
|
112
138
|
if (e.key === key && e.newValue !== null) {
|
|
113
|
-
try { s.set(JSON.parse(e.newValue)); } catch {
|
|
139
|
+
try { s.set(JSON.parse(e.newValue)); } catch (err) {
|
|
140
|
+
if (__DEV__) console.warn('[what] localStorage parse failed:', err);
|
|
141
|
+
}
|
|
114
142
|
}
|
|
143
|
+
};
|
|
144
|
+
window.addEventListener('storage', storageHandler);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Register cleanup if inside a component context
|
|
148
|
+
const ctx = _getCurrentComponentRef?.();
|
|
149
|
+
if (ctx) {
|
|
150
|
+
ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
|
|
151
|
+
ctx._cleanupCallbacks.push(() => {
|
|
152
|
+
dispose();
|
|
153
|
+
if (storageHandler) window.removeEventListener('storage', storageHandler);
|
|
115
154
|
});
|
|
116
155
|
}
|
|
117
156
|
|
|
@@ -131,6 +170,30 @@ export function Portal({ target, children }) {
|
|
|
131
170
|
return { tag: '__portal', props: { container }, children: Array.isArray(children) ? children : [children], _vnode: true };
|
|
132
171
|
}
|
|
133
172
|
|
|
173
|
+
// --- useClickOutside ---
|
|
174
|
+
// Detect clicks outside a ref'd element. Essential for dropdowns, modals, popovers.
|
|
175
|
+
export function useClickOutside(ref, handler) {
|
|
176
|
+
if (typeof document === 'undefined') return;
|
|
177
|
+
|
|
178
|
+
const listener = (e) => {
|
|
179
|
+
const el = ref.current || ref;
|
|
180
|
+
if (!el || el.contains(e.target)) return;
|
|
181
|
+
handler(e);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
document.addEventListener('mousedown', listener);
|
|
185
|
+
document.addEventListener('touchstart', listener);
|
|
186
|
+
|
|
187
|
+
const ctx = _getCurrentComponentRef?.();
|
|
188
|
+
if (ctx) {
|
|
189
|
+
ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
|
|
190
|
+
ctx._cleanupCallbacks.push(() => {
|
|
191
|
+
document.removeEventListener('mousedown', listener);
|
|
192
|
+
document.removeEventListener('touchstart', listener);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
134
197
|
// --- Transition helper ---
|
|
135
198
|
// Animate elements in/out. Returns props to spread on the element.
|
|
136
199
|
export function transition(name, active) {
|
package/dist/hooks.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// What Framework - Hooks
|
|
2
2
|
// React-familiar hooks backed by signals. Zero overhead when deps don't change.
|
|
3
3
|
|
|
4
|
-
import { signal, computed, effect, batch, untrack } from './reactive.js';
|
|
5
|
-
import { getCurrentComponent
|
|
4
|
+
import { signal, computed, effect, batch, untrack, __DEV__ } from './reactive.js';
|
|
5
|
+
import { getCurrentComponent } from './dom.js';
|
|
6
6
|
|
|
7
7
|
function getCtx() {
|
|
8
8
|
const ctx = getCurrentComponent();
|
|
9
|
-
if (!ctx)
|
|
9
|
+
if (!ctx) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
'[what] Hooks must be called inside a component function. ' +
|
|
12
|
+
'If you need reactive state outside a component, use signal() directly.'
|
|
13
|
+
);
|
|
14
|
+
}
|
|
10
15
|
return ctx;
|
|
11
16
|
}
|
|
12
17
|
|
|
@@ -129,15 +134,26 @@ export function useRef(initial) {
|
|
|
129
134
|
|
|
130
135
|
// --- useContext ---
|
|
131
136
|
// Read from the nearest Provider in the component tree, or the default value.
|
|
137
|
+
// Uses _parentCtx chain (persistent tree) instead of componentStack (runtime stack)
|
|
138
|
+
// so context works correctly in re-renders, effects, and event handlers.
|
|
132
139
|
|
|
133
140
|
export function useContext(context) {
|
|
134
|
-
// Walk up the
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
// Walk up the _parentCtx chain to find the nearest provider
|
|
142
|
+
let ctx = getCurrentComponent();
|
|
143
|
+
if (__DEV__ && !ctx) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[what] useContext(${context?.displayName || 'Context'}) called outside of component render. ` +
|
|
146
|
+
'useContext must be called during component rendering, not inside effects or event handlers. ' +
|
|
147
|
+
'Store the context value in a variable during render and use that variable in your callback.'
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
while (ctx) {
|
|
138
151
|
if (ctx._contextValues && ctx._contextValues.has(context)) {
|
|
139
|
-
|
|
152
|
+
const val = ctx._contextValues.get(context);
|
|
153
|
+
// If the stored value is a signal, read it to subscribe
|
|
154
|
+
return (val && val._signal) ? val() : val;
|
|
140
155
|
}
|
|
156
|
+
ctx = ctx._parentCtx;
|
|
141
157
|
}
|
|
142
158
|
return context._defaultValue;
|
|
143
159
|
}
|
|
@@ -145,15 +161,24 @@ export function useContext(context) {
|
|
|
145
161
|
// --- createContext ---
|
|
146
162
|
// Tree-scoped context: Provider sets value for its subtree only.
|
|
147
163
|
// Multiple providers can coexist — each subtree sees its own value.
|
|
164
|
+
// Context values are wrapped in signals so consumers re-render when values change.
|
|
148
165
|
|
|
149
166
|
export function createContext(defaultValue) {
|
|
150
167
|
const context = {
|
|
151
168
|
_defaultValue: defaultValue,
|
|
152
169
|
Provider: ({ value, children }) => {
|
|
153
|
-
// Store context value on the current component's context
|
|
154
170
|
const ctx = getCtx();
|
|
155
171
|
if (!ctx._contextValues) ctx._contextValues = new Map();
|
|
156
|
-
ctx.
|
|
172
|
+
if (!ctx._contextSignals) ctx._contextSignals = new Map();
|
|
173
|
+
|
|
174
|
+
// Create or update the context signal
|
|
175
|
+
if (!ctx._contextSignals.has(context)) {
|
|
176
|
+
const s = signal(value);
|
|
177
|
+
ctx._contextSignals.set(context, s);
|
|
178
|
+
ctx._contextValues.set(context, s);
|
|
179
|
+
} else {
|
|
180
|
+
ctx._contextSignals.get(context).set(value);
|
|
181
|
+
}
|
|
157
182
|
return children;
|
|
158
183
|
},
|
|
159
184
|
};
|
|
@@ -209,26 +234,33 @@ export function createResource(fetcher, options = {}) {
|
|
|
209
234
|
const loading = signal(!options.initialValue);
|
|
210
235
|
const error = signal(null);
|
|
211
236
|
|
|
212
|
-
let
|
|
237
|
+
let controller = null;
|
|
213
238
|
|
|
214
239
|
const refetch = async (source) => {
|
|
240
|
+
// Abort previous request
|
|
241
|
+
if (controller) controller.abort();
|
|
242
|
+
controller = new AbortController();
|
|
243
|
+
const { signal: abortSignal } = controller;
|
|
244
|
+
|
|
215
245
|
loading.set(true);
|
|
216
246
|
error.set(null);
|
|
217
247
|
|
|
218
248
|
try {
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
249
|
+
const result = await fetcher(source, { signal: abortSignal });
|
|
250
|
+
|
|
251
|
+
// Only update if not aborted
|
|
252
|
+
if (!abortSignal.aborted) {
|
|
253
|
+
batch(() => {
|
|
254
|
+
data.set(result);
|
|
255
|
+
loading.set(false);
|
|
256
|
+
});
|
|
227
257
|
}
|
|
228
258
|
} catch (e) {
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
259
|
+
if (!abortSignal.aborted) {
|
|
260
|
+
batch(() => {
|
|
261
|
+
error.set(e);
|
|
262
|
+
loading.set(false);
|
|
263
|
+
});
|
|
232
264
|
}
|
|
233
265
|
}
|
|
234
266
|
};
|
|
@@ -237,6 +269,15 @@ export function createResource(fetcher, options = {}) {
|
|
|
237
269
|
data.set(typeof value === 'function' ? value(data()) : value);
|
|
238
270
|
};
|
|
239
271
|
|
|
272
|
+
// Register cleanup with component lifecycle: abort on unmount
|
|
273
|
+
const ctx = getCurrentComponent?.();
|
|
274
|
+
if (ctx) {
|
|
275
|
+
ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
|
|
276
|
+
ctx._cleanupCallbacks.push(() => {
|
|
277
|
+
if (controller) controller.abort();
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
240
281
|
// Initial fetch if no initial value
|
|
241
282
|
if (!options.initialValue) {
|
|
242
283
|
refetch(options.source);
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
// The closest framework to vanilla JS.
|
|
3
3
|
|
|
4
4
|
// Reactive primitives
|
|
5
|
-
export { signal, computed, effect, batch, untrack } from './reactive.js';
|
|
5
|
+
export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot } from './reactive.js';
|
|
6
|
+
|
|
7
|
+
// Fine-grained rendering primitives
|
|
8
|
+
export { template, insert, mapArray, spread, delegateEvents, on, classList } from './render.js';
|
|
6
9
|
|
|
7
10
|
// Virtual DOM
|
|
8
11
|
export { h, Fragment, html } from './h.js';
|
|
@@ -31,7 +34,7 @@ export {
|
|
|
31
34
|
export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
|
|
32
35
|
|
|
33
36
|
// Store
|
|
34
|
-
export { createStore, storeComputed, atom } from './store.js';
|
|
37
|
+
export { createStore, derived, storeComputed, atom } from './store.js';
|
|
35
38
|
|
|
36
39
|
// Head management
|
|
37
40
|
export { Head, clearHead } from './head.js';
|
|
@@ -46,6 +49,7 @@ export {
|
|
|
46
49
|
throttle,
|
|
47
50
|
useMediaQuery,
|
|
48
51
|
useLocalStorage,
|
|
52
|
+
useClickOutside,
|
|
49
53
|
Portal,
|
|
50
54
|
transition,
|
|
51
55
|
} from './helpers.js';
|
package/dist/reactive.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// What Framework - Reactive Primitives
|
|
2
2
|
// Signals + Effects: fine-grained reactivity without virtual DOM overhead
|
|
3
3
|
|
|
4
|
+
// Dev-mode flag — build tools can dead-code-eliminate when false
|
|
5
|
+
export const __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production' || true;
|
|
6
|
+
|
|
4
7
|
let currentEffect = null;
|
|
8
|
+
let currentRoot = null;
|
|
5
9
|
let batchDepth = 0;
|
|
6
|
-
let pendingEffects =
|
|
10
|
+
let pendingEffects = [];
|
|
7
11
|
|
|
8
12
|
// --- Signal ---
|
|
9
13
|
// A reactive value. Reading inside an effect auto-tracks the dependency.
|
|
@@ -16,7 +20,7 @@ export function signal(initial) {
|
|
|
16
20
|
function read() {
|
|
17
21
|
if (currentEffect) {
|
|
18
22
|
subs.add(currentEffect);
|
|
19
|
-
currentEffect.deps.
|
|
23
|
+
currentEffect.deps.push(subs); // Track reverse dep for cleanup
|
|
20
24
|
}
|
|
21
25
|
return value;
|
|
22
26
|
}
|
|
@@ -48,19 +52,24 @@ export function computed(fn) {
|
|
|
48
52
|
const inner = _createEffect(() => {
|
|
49
53
|
value = fn();
|
|
50
54
|
dirty = false;
|
|
51
|
-
|
|
52
|
-
}, { lazy: true });
|
|
55
|
+
}, true);
|
|
53
56
|
|
|
54
57
|
function read() {
|
|
55
58
|
if (currentEffect) {
|
|
56
59
|
subs.add(currentEffect);
|
|
57
|
-
currentEffect.deps.
|
|
60
|
+
currentEffect.deps.push(subs);
|
|
58
61
|
}
|
|
59
62
|
if (dirty) _runEffect(inner);
|
|
60
63
|
return value;
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
|
|
66
|
+
// When a dependency changes, mark dirty AND propagate to our subscribers.
|
|
67
|
+
// This is how effects that read this computed know to re-run:
|
|
68
|
+
// signal changes → computed._onNotify → computed's subs get notified.
|
|
69
|
+
inner._onNotify = () => {
|
|
70
|
+
dirty = true;
|
|
71
|
+
notify(subs);
|
|
72
|
+
};
|
|
64
73
|
|
|
65
74
|
read._signal = true;
|
|
66
75
|
read.peek = () => {
|
|
@@ -75,10 +84,25 @@ export function computed(fn) {
|
|
|
75
84
|
// Runs a function, auto-tracking signal reads. Re-runs when deps change.
|
|
76
85
|
// Returns a dispose function.
|
|
77
86
|
|
|
78
|
-
export function effect(fn) {
|
|
87
|
+
export function effect(fn, opts) {
|
|
79
88
|
const e = _createEffect(fn);
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
// First run: skip cleanup (deps is empty), just run and track
|
|
90
|
+
const prev = currentEffect;
|
|
91
|
+
currentEffect = e;
|
|
92
|
+
try {
|
|
93
|
+
const result = e.fn();
|
|
94
|
+
if (typeof result === 'function') e._cleanup = result;
|
|
95
|
+
} finally {
|
|
96
|
+
currentEffect = prev;
|
|
97
|
+
}
|
|
98
|
+
// Mark as stable after first run — subsequent re-runs skip cleanup/re-subscribe
|
|
99
|
+
if (opts?.stable) e._stable = true;
|
|
100
|
+
const dispose = () => _disposeEffect(e);
|
|
101
|
+
// Register with current root for automatic cleanup
|
|
102
|
+
if (currentRoot) {
|
|
103
|
+
currentRoot.disposals.push(dispose);
|
|
104
|
+
}
|
|
105
|
+
return dispose;
|
|
82
106
|
}
|
|
83
107
|
|
|
84
108
|
// --- Batch ---
|
|
@@ -96,22 +120,47 @@ export function batch(fn) {
|
|
|
96
120
|
|
|
97
121
|
// --- Internals ---
|
|
98
122
|
|
|
99
|
-
function _createEffect(fn,
|
|
123
|
+
function _createEffect(fn, lazy) {
|
|
100
124
|
return {
|
|
101
125
|
fn,
|
|
102
|
-
deps:
|
|
103
|
-
lazy:
|
|
126
|
+
deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
|
|
127
|
+
lazy: lazy || false,
|
|
104
128
|
_onNotify: null,
|
|
105
129
|
disposed: false,
|
|
130
|
+
_pending: false,
|
|
131
|
+
_stable: false, // stable effects skip cleanup/re-subscribe on re-run
|
|
106
132
|
};
|
|
107
133
|
}
|
|
108
134
|
|
|
109
135
|
function _runEffect(e) {
|
|
110
136
|
if (e.disposed) return;
|
|
137
|
+
|
|
138
|
+
// Stable effect fast path: deps don't change, skip cleanup/re-subscribe.
|
|
139
|
+
// Effect stays subscribed to its signals from the first run.
|
|
140
|
+
if (e._stable) {
|
|
141
|
+
if (e._cleanup) {
|
|
142
|
+
try { e._cleanup(); } catch (err) {
|
|
143
|
+
if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
|
|
144
|
+
}
|
|
145
|
+
e._cleanup = null;
|
|
146
|
+
}
|
|
147
|
+
const prev = currentEffect;
|
|
148
|
+
currentEffect = null; // Don't re-track deps (already subscribed)
|
|
149
|
+
try {
|
|
150
|
+
const result = e.fn();
|
|
151
|
+
if (typeof result === 'function') e._cleanup = result;
|
|
152
|
+
} finally {
|
|
153
|
+
currentEffect = prev;
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
111
158
|
cleanup(e);
|
|
112
159
|
// Run effect cleanup from previous run
|
|
113
160
|
if (e._cleanup) {
|
|
114
|
-
try { e._cleanup(); } catch (err) {
|
|
161
|
+
try { e._cleanup(); } catch (err) {
|
|
162
|
+
if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
|
|
163
|
+
}
|
|
115
164
|
e._cleanup = null;
|
|
116
165
|
}
|
|
117
166
|
const prev = currentEffect;
|
|
@@ -132,40 +181,131 @@ function _disposeEffect(e) {
|
|
|
132
181
|
cleanup(e);
|
|
133
182
|
// Run cleanup on dispose
|
|
134
183
|
if (e._cleanup) {
|
|
135
|
-
try { e._cleanup(); } catch (err) {
|
|
184
|
+
try { e._cleanup(); } catch (err) {
|
|
185
|
+
if (__DEV__) console.warn('[what] Error in effect cleanup on dispose:', err);
|
|
186
|
+
}
|
|
136
187
|
e._cleanup = null;
|
|
137
188
|
}
|
|
138
189
|
}
|
|
139
190
|
|
|
140
191
|
function cleanup(e) {
|
|
141
|
-
|
|
142
|
-
|
|
192
|
+
const deps = e.deps;
|
|
193
|
+
for (let i = 0; i < deps.length; i++) deps[i].delete(e);
|
|
194
|
+
deps.length = 0;
|
|
143
195
|
}
|
|
144
196
|
|
|
145
197
|
function notify(subs) {
|
|
146
|
-
|
|
147
|
-
const snapshot = [...subs];
|
|
148
|
-
for (const e of snapshot) {
|
|
198
|
+
for (const e of subs) {
|
|
149
199
|
if (e.disposed) continue;
|
|
150
200
|
if (e._onNotify) {
|
|
151
201
|
e._onNotify();
|
|
152
|
-
|
|
153
|
-
|
|
202
|
+
} else if (batchDepth === 0 && e._stable) {
|
|
203
|
+
// Inline execution for stable effects: skip queue + flush + _runEffect overhead.
|
|
204
|
+
// Safe because stable effects have fixed deps (no re-subscribe needed).
|
|
205
|
+
const prev = currentEffect;
|
|
206
|
+
currentEffect = null;
|
|
207
|
+
try {
|
|
208
|
+
const result = e.fn();
|
|
209
|
+
if (typeof result === 'function') {
|
|
210
|
+
if (e._cleanup) try { e._cleanup(); } catch (err) {}
|
|
211
|
+
e._cleanup = result;
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
if (__DEV__) console.warn('[what] Error in stable effect:', err);
|
|
215
|
+
} finally {
|
|
216
|
+
currentEffect = prev;
|
|
217
|
+
}
|
|
218
|
+
} else if (!e._pending) {
|
|
219
|
+
e._pending = true;
|
|
220
|
+
pendingEffects.push(e);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (batchDepth === 0 && pendingEffects.length > 0) scheduleMicrotask();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let microtaskScheduled = false;
|
|
227
|
+
function scheduleMicrotask() {
|
|
228
|
+
if (!microtaskScheduled) {
|
|
229
|
+
microtaskScheduled = true;
|
|
230
|
+
queueMicrotask(() => {
|
|
231
|
+
microtaskScheduled = false;
|
|
232
|
+
flush();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function flush() {
|
|
238
|
+
let iterations = 0;
|
|
239
|
+
while (pendingEffects.length > 0 && iterations < 100) {
|
|
240
|
+
const batch = pendingEffects;
|
|
241
|
+
pendingEffects = [];
|
|
242
|
+
for (let i = 0; i < batch.length; i++) {
|
|
243
|
+
const e = batch[i];
|
|
244
|
+
e._pending = false;
|
|
245
|
+
if (!e.disposed && !e._onNotify) _runEffect(e);
|
|
154
246
|
}
|
|
155
|
-
|
|
156
|
-
|
|
247
|
+
iterations++;
|
|
248
|
+
}
|
|
249
|
+
if (iterations >= 100) {
|
|
250
|
+
if (__DEV__) {
|
|
251
|
+
const remaining = pendingEffects.slice(0, 3);
|
|
252
|
+
const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
|
|
253
|
+
console.warn(
|
|
254
|
+
`[what] Possible infinite effect loop detected (100 iterations). ` +
|
|
255
|
+
`Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
|
|
256
|
+
`Use untrack() to read signals without subscribing. ` +
|
|
257
|
+
`Looping effects: ${effectNames.join(', ')}`
|
|
258
|
+
);
|
|
157
259
|
} else {
|
|
158
|
-
|
|
260
|
+
console.warn('[what] Possible infinite effect loop detected');
|
|
159
261
|
}
|
|
262
|
+
for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
|
|
263
|
+
pendingEffects.length = 0;
|
|
160
264
|
}
|
|
161
265
|
}
|
|
162
266
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
267
|
+
// --- Memo ---
|
|
268
|
+
// Eager computed that only propagates when the value actually changes.
|
|
269
|
+
// Reads deps eagerly (unlike lazy computed), but skips notifying subscribers
|
|
270
|
+
// when the recomputed value is the same. Critical for patterns like:
|
|
271
|
+
// memo(() => selected() === item().id) — 1000 memos, only 2 change
|
|
272
|
+
export function memo(fn) {
|
|
273
|
+
let value;
|
|
274
|
+
const subs = new Set();
|
|
275
|
+
|
|
276
|
+
const e = _createEffect(() => {
|
|
277
|
+
const next = fn();
|
|
278
|
+
if (!Object.is(value, next)) {
|
|
279
|
+
value = next;
|
|
280
|
+
notify(subs);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
_runEffect(e);
|
|
285
|
+
|
|
286
|
+
// Register with current root
|
|
287
|
+
if (currentRoot) {
|
|
288
|
+
currentRoot.disposals.push(() => _disposeEffect(e));
|
|
168
289
|
}
|
|
290
|
+
|
|
291
|
+
function read() {
|
|
292
|
+
if (currentEffect) {
|
|
293
|
+
subs.add(currentEffect);
|
|
294
|
+
currentEffect.deps.push(subs);
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
read._signal = true;
|
|
300
|
+
read.peek = () => value;
|
|
301
|
+
return read;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- flushSync ---
|
|
305
|
+
// Force all pending effects to run synchronously. Use sparingly.
|
|
306
|
+
export function flushSync() {
|
|
307
|
+
microtaskScheduled = false;
|
|
308
|
+
flush();
|
|
169
309
|
}
|
|
170
310
|
|
|
171
311
|
// --- Untrack ---
|
|
@@ -179,3 +319,23 @@ export function untrack(fn) {
|
|
|
179
319
|
currentEffect = prev;
|
|
180
320
|
}
|
|
181
321
|
}
|
|
322
|
+
|
|
323
|
+
// --- createRoot ---
|
|
324
|
+
// Isolated reactive scope. All effects created inside are tracked and disposed together.
|
|
325
|
+
// Essential for per-item cleanup in reactive lists.
|
|
326
|
+
export function createRoot(fn) {
|
|
327
|
+
const prevRoot = currentRoot;
|
|
328
|
+
const root = { disposals: [], owner: currentRoot };
|
|
329
|
+
currentRoot = root;
|
|
330
|
+
try {
|
|
331
|
+
const dispose = () => {
|
|
332
|
+
for (let i = root.disposals.length - 1; i >= 0; i--) {
|
|
333
|
+
root.disposals[i]();
|
|
334
|
+
}
|
|
335
|
+
root.disposals.length = 0;
|
|
336
|
+
};
|
|
337
|
+
return fn(dispose);
|
|
338
|
+
} finally {
|
|
339
|
+
currentRoot = prevRoot;
|
|
340
|
+
}
|
|
341
|
+
}
|