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/src/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
- mq.addEventListener('change', (e) => s.set(e.matches));
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 { /* quota exceeded, etc */ }
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
- window.addEventListener('storage', (e) => {
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/src/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, getComponentStack as _getComponentStack } from './dom.js';
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) throw new Error('Hooks must be called inside a component');
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 component stack to find the nearest provider for this context
135
- const stack = _getComponentStack();
136
- for (let i = stack.length - 1; i >= 0; i--) {
137
- const ctx = stack[i];
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
- return ctx._contextValues.get(context);
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._contextValues.set(context, value);
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 currentFetch = null;
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 fetchPromise = fetcher(source);
220
- currentFetch = fetchPromise;
221
- const result = await fetchPromise;
222
-
223
- // Only update if this is still the current fetch
224
- if (currentFetch === fetchPromise) {
225
- data.set(result);
226
- loading.set(false);
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 (currentFetch === fetchPromise) {
230
- error.set(e);
231
- loading.set(false);
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/src/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';
@@ -0,0 +1,19 @@
1
+ // What Framework — JSX Dev Runtime
2
+ // Same as jsx-runtime but used in development mode by Vite.
3
+
4
+ import { h, Fragment } from './h.js';
5
+
6
+ export { Fragment };
7
+
8
+ export function jsxDEV(type, props, key) {
9
+ if (props == null) return h(type, null);
10
+ const { children, ...rest } = props;
11
+ if (key !== undefined) rest.key = key;
12
+ if (children === undefined) return h(type, rest);
13
+ if (Array.isArray(children)) return h(type, rest, ...children);
14
+ return h(type, rest, children);
15
+ }
16
+
17
+ // Also export jsx/jsxs for compatibility — some bundlers use these even in dev
18
+ export const jsx = jsxDEV;
19
+ export const jsxs = jsxDEV;
@@ -0,0 +1,21 @@
1
+ // What Framework — JSX Automatic Runtime
2
+ // Used by: jsxImportSource: "what-framework" (or "what-core")
3
+ // Vite/esbuild import this automatically when using the "react-jsx" transform.
4
+
5
+ import { h, Fragment } from './h.js';
6
+
7
+ export { Fragment };
8
+
9
+ // Automatic JSX transform signature: jsx(type, { children, ...props }, key)
10
+ // What's h() signature: h(type, props, ...children)
11
+ export function jsx(type, props, key) {
12
+ if (props == null) return h(type, null);
13
+ const { children, ...rest } = props;
14
+ if (key !== undefined) rest.key = key;
15
+ if (children === undefined) return h(type, rest);
16
+ if (Array.isArray(children)) return h(type, rest, ...children);
17
+ return h(type, rest, children);
18
+ }
19
+
20
+ // jsxs = jsx for static children (multiple). Same behavior for What.
21
+ export const jsxs = jsx;
package/src/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 = new Set();
10
+ let pendingEffects = [];
7
11
 
8
12
  // --- Signal ---
9
13
  // A reactive value. Reading inside an effect auto-tracks the dependency.
@@ -13,29 +17,38 @@ export function signal(initial) {
13
17
  let value = initial;
14
18
  const subs = new Set();
15
19
 
16
- function read() {
17
- if (currentEffect) {
18
- subs.add(currentEffect);
19
- currentEffect.deps.add(subs); // Track reverse dep for cleanup
20
+ // Unified getter/setter: sig() reads, sig(newVal) writes
21
+ function sig(...args) {
22
+ if (args.length === 0) {
23
+ // Read
24
+ if (currentEffect) {
25
+ subs.add(currentEffect);
26
+ currentEffect.deps.push(subs);
27
+ }
28
+ return value;
20
29
  }
21
- return value;
30
+ // Write
31
+ const nextVal = typeof args[0] === 'function' ? args[0](value) : args[0];
32
+ if (Object.is(value, nextVal)) return;
33
+ value = nextVal;
34
+ notify(subs);
22
35
  }
23
36
 
24
- read.set = (next) => {
37
+ sig.set = (next) => {
25
38
  const nextVal = typeof next === 'function' ? next(value) : next;
26
39
  if (Object.is(value, nextVal)) return;
27
40
  value = nextVal;
28
41
  notify(subs);
29
42
  };
30
43
 
31
- read.peek = () => value;
44
+ sig.peek = () => value;
32
45
 
33
- read.subscribe = (fn) => {
34
- return effect(() => fn(read()));
46
+ sig.subscribe = (fn) => {
47
+ return effect(() => fn(sig()));
35
48
  };
36
49
 
37
- read._signal = true;
38
- return read;
50
+ sig._signal = true;
51
+ return sig;
39
52
  }
40
53
 
41
54
  // --- Computed ---
@@ -48,19 +61,24 @@ export function computed(fn) {
48
61
  const inner = _createEffect(() => {
49
62
  value = fn();
50
63
  dirty = false;
51
- notify(subs);
52
- }, { lazy: true });
64
+ }, true);
53
65
 
54
66
  function read() {
55
67
  if (currentEffect) {
56
68
  subs.add(currentEffect);
57
- currentEffect.deps.add(subs);
69
+ currentEffect.deps.push(subs);
58
70
  }
59
71
  if (dirty) _runEffect(inner);
60
72
  return value;
61
73
  }
62
74
 
63
- inner._onNotify = () => { dirty = true; };
75
+ // When a dependency changes, mark dirty AND propagate to our subscribers.
76
+ // This is how effects that read this computed know to re-run:
77
+ // signal changes → computed._onNotify → computed's subs get notified.
78
+ inner._onNotify = () => {
79
+ dirty = true;
80
+ notify(subs);
81
+ };
64
82
 
65
83
  read._signal = true;
66
84
  read.peek = () => {
@@ -75,10 +93,25 @@ export function computed(fn) {
75
93
  // Runs a function, auto-tracking signal reads. Re-runs when deps change.
76
94
  // Returns a dispose function.
77
95
 
78
- export function effect(fn) {
96
+ export function effect(fn, opts) {
79
97
  const e = _createEffect(fn);
80
- _runEffect(e);
81
- return () => _disposeEffect(e);
98
+ // First run: skip cleanup (deps is empty), just run and track
99
+ const prev = currentEffect;
100
+ currentEffect = e;
101
+ try {
102
+ const result = e.fn();
103
+ if (typeof result === 'function') e._cleanup = result;
104
+ } finally {
105
+ currentEffect = prev;
106
+ }
107
+ // Mark as stable after first run — subsequent re-runs skip cleanup/re-subscribe
108
+ if (opts?.stable) e._stable = true;
109
+ const dispose = () => _disposeEffect(e);
110
+ // Register with current root for automatic cleanup
111
+ if (currentRoot) {
112
+ currentRoot.disposals.push(dispose);
113
+ }
114
+ return dispose;
82
115
  }
83
116
 
84
117
  // --- Batch ---
@@ -96,22 +129,47 @@ export function batch(fn) {
96
129
 
97
130
  // --- Internals ---
98
131
 
99
- function _createEffect(fn, opts = {}) {
132
+ function _createEffect(fn, lazy) {
100
133
  return {
101
134
  fn,
102
- deps: new Set(), // subscriber sets this effect belongs to
103
- lazy: opts.lazy || false,
135
+ deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
136
+ lazy: lazy || false,
104
137
  _onNotify: null,
105
138
  disposed: false,
139
+ _pending: false,
140
+ _stable: false, // stable effects skip cleanup/re-subscribe on re-run
106
141
  };
107
142
  }
108
143
 
109
144
  function _runEffect(e) {
110
145
  if (e.disposed) return;
146
+
147
+ // Stable effect fast path: deps don't change, skip cleanup/re-subscribe.
148
+ // Effect stays subscribed to its signals from the first run.
149
+ if (e._stable) {
150
+ if (e._cleanup) {
151
+ try { e._cleanup(); } catch (err) {
152
+ if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
153
+ }
154
+ e._cleanup = null;
155
+ }
156
+ const prev = currentEffect;
157
+ currentEffect = null; // Don't re-track deps (already subscribed)
158
+ try {
159
+ const result = e.fn();
160
+ if (typeof result === 'function') e._cleanup = result;
161
+ } finally {
162
+ currentEffect = prev;
163
+ }
164
+ return;
165
+ }
166
+
111
167
  cleanup(e);
112
168
  // Run effect cleanup from previous run
113
169
  if (e._cleanup) {
114
- try { e._cleanup(); } catch (err) { /* cleanup error */ }
170
+ try { e._cleanup(); } catch (err) {
171
+ if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
172
+ }
115
173
  e._cleanup = null;
116
174
  }
117
175
  const prev = currentEffect;
@@ -132,40 +190,131 @@ function _disposeEffect(e) {
132
190
  cleanup(e);
133
191
  // Run cleanup on dispose
134
192
  if (e._cleanup) {
135
- try { e._cleanup(); } catch (err) { /* cleanup error */ }
193
+ try { e._cleanup(); } catch (err) {
194
+ if (__DEV__) console.warn('[what] Error in effect cleanup on dispose:', err);
195
+ }
136
196
  e._cleanup = null;
137
197
  }
138
198
  }
139
199
 
140
200
  function cleanup(e) {
141
- for (const dep of e.deps) dep.delete(e);
142
- e.deps.clear();
201
+ const deps = e.deps;
202
+ for (let i = 0; i < deps.length; i++) deps[i].delete(e);
203
+ deps.length = 0;
143
204
  }
144
205
 
145
206
  function notify(subs) {
146
- // Snapshot to avoid infinite loop when effects re-subscribe during run
147
- const snapshot = [...subs];
148
- for (const e of snapshot) {
207
+ for (const e of subs) {
149
208
  if (e.disposed) continue;
150
209
  if (e._onNotify) {
151
210
  e._onNotify();
152
- if (batchDepth > 0) pendingEffects.add(e);
153
- continue;
211
+ } else if (batchDepth === 0 && e._stable) {
212
+ // Inline execution for stable effects: skip queue + flush + _runEffect overhead.
213
+ // Safe because stable effects have fixed deps (no re-subscribe needed).
214
+ const prev = currentEffect;
215
+ currentEffect = null;
216
+ try {
217
+ const result = e.fn();
218
+ if (typeof result === 'function') {
219
+ if (e._cleanup) try { e._cleanup(); } catch (err) {}
220
+ e._cleanup = result;
221
+ }
222
+ } catch (err) {
223
+ if (__DEV__) console.warn('[what] Error in stable effect:', err);
224
+ } finally {
225
+ currentEffect = prev;
226
+ }
227
+ } else if (!e._pending) {
228
+ e._pending = true;
229
+ pendingEffects.push(e);
230
+ }
231
+ }
232
+ if (batchDepth === 0 && pendingEffects.length > 0) scheduleMicrotask();
233
+ }
234
+
235
+ let microtaskScheduled = false;
236
+ function scheduleMicrotask() {
237
+ if (!microtaskScheduled) {
238
+ microtaskScheduled = true;
239
+ queueMicrotask(() => {
240
+ microtaskScheduled = false;
241
+ flush();
242
+ });
243
+ }
244
+ }
245
+
246
+ function flush() {
247
+ let iterations = 0;
248
+ while (pendingEffects.length > 0 && iterations < 100) {
249
+ const batch = pendingEffects;
250
+ pendingEffects = [];
251
+ for (let i = 0; i < batch.length; i++) {
252
+ const e = batch[i];
253
+ e._pending = false;
254
+ if (!e.disposed && !e._onNotify) _runEffect(e);
154
255
  }
155
- if (batchDepth > 0) {
156
- pendingEffects.add(e);
256
+ iterations++;
257
+ }
258
+ if (iterations >= 100) {
259
+ if (__DEV__) {
260
+ const remaining = pendingEffects.slice(0, 3);
261
+ const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
262
+ console.warn(
263
+ `[what] Possible infinite effect loop detected (100 iterations). ` +
264
+ `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
265
+ `Use untrack() to read signals without subscribing. ` +
266
+ `Looping effects: ${effectNames.join(', ')}`
267
+ );
157
268
  } else {
158
- _runEffect(e);
269
+ console.warn('[what] Possible infinite effect loop detected');
159
270
  }
271
+ for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
272
+ pendingEffects.length = 0;
160
273
  }
161
274
  }
162
275
 
163
- function flush() {
164
- const effects = [...pendingEffects];
165
- pendingEffects.clear();
166
- for (const e of effects) {
167
- if (!e.disposed && !e._onNotify) _runEffect(e);
276
+ // --- Memo ---
277
+ // Eager computed that only propagates when the value actually changes.
278
+ // Reads deps eagerly (unlike lazy computed), but skips notifying subscribers
279
+ // when the recomputed value is the same. Critical for patterns like:
280
+ // memo(() => selected() === item().id) 1000 memos, only 2 change
281
+ export function memo(fn) {
282
+ let value;
283
+ const subs = new Set();
284
+
285
+ const e = _createEffect(() => {
286
+ const next = fn();
287
+ if (!Object.is(value, next)) {
288
+ value = next;
289
+ notify(subs);
290
+ }
291
+ });
292
+
293
+ _runEffect(e);
294
+
295
+ // Register with current root
296
+ if (currentRoot) {
297
+ currentRoot.disposals.push(() => _disposeEffect(e));
298
+ }
299
+
300
+ function read() {
301
+ if (currentEffect) {
302
+ subs.add(currentEffect);
303
+ currentEffect.deps.push(subs);
304
+ }
305
+ return value;
168
306
  }
307
+
308
+ read._signal = true;
309
+ read.peek = () => value;
310
+ return read;
311
+ }
312
+
313
+ // --- flushSync ---
314
+ // Force all pending effects to run synchronously. Use sparingly.
315
+ export function flushSync() {
316
+ microtaskScheduled = false;
317
+ flush();
169
318
  }
170
319
 
171
320
  // --- Untrack ---
@@ -179,3 +328,23 @@ export function untrack(fn) {
179
328
  currentEffect = prev;
180
329
  }
181
330
  }
331
+
332
+ // --- createRoot ---
333
+ // Isolated reactive scope. All effects created inside are tracked and disposed together.
334
+ // Essential for per-item cleanup in reactive lists.
335
+ export function createRoot(fn) {
336
+ const prevRoot = currentRoot;
337
+ const root = { disposals: [], owner: currentRoot };
338
+ currentRoot = root;
339
+ try {
340
+ const dispose = () => {
341
+ for (let i = root.disposals.length - 1; i >= 0; i--) {
342
+ root.disposals[i]();
343
+ }
344
+ root.disposals.length = 0;
345
+ };
346
+ return fn(dispose);
347
+ } finally {
348
+ currentRoot = prevRoot;
349
+ }
350
+ }