what-core 0.4.1 → 0.5.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/dist/reactive.js CHANGED
@@ -1,341 +1,268 @@
1
- // What Framework - Reactive Primitives
2
- // Signals + Effects: fine-grained reactivity without virtual DOM overhead
3
-
4
- // Dev-mode flag — build tools can dead-code-eliminate when false
5
1
  export const __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production' || true;
6
-
7
2
  let currentEffect = null;
8
3
  let currentRoot = null;
9
4
  let batchDepth = 0;
10
5
  let pendingEffects = [];
11
-
12
- // --- Signal ---
13
- // A reactive value. Reading inside an effect auto-tracks the dependency.
14
- // Writing triggers only the effects that depend on this signal.
15
-
16
6
  export function signal(initial) {
17
- let value = initial;
18
- const subs = new Set();
19
-
20
- function read() {
21
- if (currentEffect) {
22
- subs.add(currentEffect);
23
- currentEffect.deps.push(subs); // Track reverse dep for cleanup
24
- }
25
- return value;
26
- }
27
-
28
- read.set = (next) => {
29
- const nextVal = typeof next === 'function' ? next(value) : next;
30
- if (Object.is(value, nextVal)) return;
31
- value = nextVal;
32
- notify(subs);
33
- };
34
-
35
- read.peek = () => value;
36
-
37
- read.subscribe = (fn) => {
38
- return effect(() => fn(read()));
39
- };
40
-
41
- read._signal = true;
42
- return read;
43
- }
44
-
45
- // --- Computed ---
46
- // Derived signal. Lazy: only recomputes when a dependency changes AND it's read.
47
-
7
+ let value = initial;
8
+ const subs = new Set();
9
+ function sig(...args) {
10
+ if (args.length === 0) {
11
+ if (currentEffect) {
12
+ subs.add(currentEffect);
13
+ currentEffect.deps.push(subs);
14
+ }
15
+ return value;
16
+ }
17
+ const nextVal = typeof args[0] === 'function' ? args[0](value) : args[0];
18
+ if (Object.is(value, nextVal)) return;
19
+ value = nextVal;
20
+ notify(subs);
21
+ }
22
+ sig.set = (next) => {
23
+ const nextVal = typeof next === 'function' ? next(value) : next;
24
+ if (Object.is(value, nextVal)) return;
25
+ value = nextVal;
26
+ notify(subs);
27
+ };
28
+ sig.peek = () => value;
29
+ sig.subscribe = (fn) => {
30
+ return effect(() => fn(sig()));
31
+ };
32
+ sig._signal = true;
33
+ return sig;
34
+ }
48
35
  export function computed(fn) {
49
- let value, dirty = true;
50
- const subs = new Set();
51
-
52
- const inner = _createEffect(() => {
53
- value = fn();
54
- dirty = false;
55
- }, true);
56
-
57
- function read() {
58
- if (currentEffect) {
59
- subs.add(currentEffect);
60
- currentEffect.deps.push(subs);
61
- }
62
- if (dirty) _runEffect(inner);
63
- return value;
64
- }
65
-
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
- };
73
-
74
- read._signal = true;
75
- read.peek = () => {
76
- if (dirty) _runEffect(inner);
77
- return value;
78
- };
79
-
80
- return read;
81
- }
82
-
83
- // --- Effect ---
84
- // Runs a function, auto-tracking signal reads. Re-runs when deps change.
85
- // Returns a dispose function.
86
-
36
+ let value, dirty = true;
37
+ const subs = new Set();
38
+ const inner = _createEffect(() => {
39
+ value = fn();
40
+ dirty = false;
41
+ }, true);
42
+ function read() {
43
+ if (currentEffect) {
44
+ subs.add(currentEffect);
45
+ currentEffect.deps.push(subs);
46
+ }
47
+ if (dirty) _runEffect(inner);
48
+ return value;
49
+ }
50
+ inner._onNotify = () => {
51
+ dirty = true;
52
+ notify(subs);
53
+ };
54
+ read._signal = true;
55
+ read.peek = () => {
56
+ if (dirty) _runEffect(inner);
57
+ return value;
58
+ };
59
+ return read;
60
+ }
87
61
  export function effect(fn, opts) {
88
- const e = _createEffect(fn);
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;
106
- }
107
-
108
- // --- Batch ---
109
- // Group multiple signal writes; effects run once at the end.
110
-
62
+ const e = _createEffect(fn);
63
+ const prev = currentEffect;
64
+ currentEffect = e;
65
+ try {
66
+ const result = e.fn();
67
+ if (typeof result === 'function') e._cleanup = result;
68
+ } finally {
69
+ currentEffect = prev;
70
+ }
71
+ if (opts?.stable) e._stable = true;
72
+ const dispose = () => _disposeEffect(e);
73
+ if (currentRoot) {
74
+ currentRoot.disposals.push(dispose);
75
+ }
76
+ return dispose;
77
+ }
111
78
  export function batch(fn) {
112
- batchDepth++;
113
- try {
114
- fn();
115
- } finally {
116
- batchDepth--;
117
- if (batchDepth === 0) flush();
118
- }
119
- }
120
-
121
- // --- Internals ---
122
-
79
+ batchDepth++;
80
+ try {
81
+ fn();
82
+ } finally {
83
+ batchDepth--;
84
+ if (batchDepth === 0) flush();
85
+ }
86
+ }
123
87
  function _createEffect(fn, lazy) {
124
- return {
125
- fn,
126
- deps: [], // array of subscriber sets (cheaper than Set for typical 1-3 deps)
127
- lazy: lazy || false,
128
- _onNotify: null,
129
- disposed: false,
130
- _pending: false,
131
- _stable: false, // stable effects skip cleanup/re-subscribe on re-run
132
- };
133
- }
134
-
88
+ return {
89
+ fn,
90
+ deps: [],
91
+ lazy: lazy || false,
92
+ _onNotify: null,
93
+ disposed: false,
94
+ _pending: false,
95
+ _stable: false,
96
+ };
97
+ }
135
98
  function _runEffect(e) {
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
-
158
- cleanup(e);
159
- // Run effect cleanup from previous run
160
- if (e._cleanup) {
161
- try { e._cleanup(); } catch (err) {
162
- if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
163
- }
164
- e._cleanup = null;
165
- }
166
- const prev = currentEffect;
167
- currentEffect = e;
168
- try {
169
- const result = e.fn();
170
- // Capture cleanup function if returned
171
- if (typeof result === 'function') {
172
- e._cleanup = result;
173
- }
174
- } finally {
175
- currentEffect = prev;
176
- }
177
- }
178
-
99
+ if (e.disposed) return;
100
+ if (e._stable) {
101
+ if (e._cleanup) {
102
+ try { e._cleanup(); } catch (err) {
103
+ if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
104
+ }
105
+ e._cleanup = null;
106
+ }
107
+ const prev = currentEffect;
108
+ currentEffect = null;
109
+ try {
110
+ const result = e.fn();
111
+ if (typeof result === 'function') e._cleanup = result;
112
+ } finally {
113
+ currentEffect = prev;
114
+ }
115
+ return;
116
+ }
117
+ cleanup(e);
118
+ if (e._cleanup) {
119
+ try { e._cleanup(); } catch (err) {
120
+ if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
121
+ }
122
+ e._cleanup = null;
123
+ }
124
+ const prev = currentEffect;
125
+ currentEffect = e;
126
+ try {
127
+ const result = e.fn();
128
+ if (typeof result === 'function') {
129
+ e._cleanup = result;
130
+ }
131
+ } finally {
132
+ currentEffect = prev;
133
+ }
134
+ }
179
135
  function _disposeEffect(e) {
180
- e.disposed = true;
181
- cleanup(e);
182
- // Run cleanup on dispose
183
- if (e._cleanup) {
184
- try { e._cleanup(); } catch (err) {
185
- if (__DEV__) console.warn('[what] Error in effect cleanup on dispose:', err);
186
- }
187
- e._cleanup = null;
188
- }
189
- }
190
-
136
+ e.disposed = true;
137
+ cleanup(e);
138
+ if (e._cleanup) {
139
+ try { e._cleanup(); } catch (err) {
140
+ if (__DEV__) console.warn('[what] Error in effect cleanup on dispose:', err);
141
+ }
142
+ e._cleanup = null;
143
+ }
144
+ }
191
145
  function cleanup(e) {
192
- const deps = e.deps;
193
- for (let i = 0; i < deps.length; i++) deps[i].delete(e);
194
- deps.length = 0;
146
+ const deps = e.deps;
147
+ for (let i = 0; i < deps.length; i++) deps[i].delete(e);
148
+ deps.length = 0;
195
149
  }
196
-
197
150
  function notify(subs) {
198
- for (const e of subs) {
199
- if (e.disposed) continue;
200
- if (e._onNotify) {
201
- e._onNotify();
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
-
151
+ for (const e of subs) {
152
+ if (e.disposed) continue;
153
+ if (e._onNotify) {
154
+ e._onNotify();
155
+ } else if (batchDepth === 0 && e._stable) {
156
+ const prev = currentEffect;
157
+ currentEffect = null;
158
+ try {
159
+ const result = e.fn();
160
+ if (typeof result === 'function') {
161
+ if (e._cleanup) try { e._cleanup(); } catch (err) {}
162
+ e._cleanup = result;
163
+ }
164
+ } catch (err) {
165
+ if (__DEV__) console.warn('[what] Error in stable effect:', err);
166
+ } finally {
167
+ currentEffect = prev;
168
+ }
169
+ } else if (!e._pending) {
170
+ e._pending = true;
171
+ pendingEffects.push(e);
172
+ }
173
+ }
174
+ if (batchDepth === 0 && pendingEffects.length > 0) scheduleMicrotask();
175
+ }
226
176
  let microtaskScheduled = false;
227
177
  function scheduleMicrotask() {
228
- if (!microtaskScheduled) {
229
- microtaskScheduled = true;
230
- queueMicrotask(() => {
231
- microtaskScheduled = false;
232
- flush();
233
- });
234
- }
235
- }
236
-
178
+ if (!microtaskScheduled) {
179
+ microtaskScheduled = true;
180
+ queueMicrotask(() => {
181
+ microtaskScheduled = false;
182
+ flush();
183
+ });
184
+ }
185
+ }
237
186
  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);
246
- }
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
- );
259
- } else {
260
- console.warn('[what] Possible infinite effect loop detected');
261
- }
262
- for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
263
- pendingEffects.length = 0;
264
- }
265
- }
266
-
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
187
+ let iterations = 0;
188
+ while (pendingEffects.length > 0 && iterations < 100) {
189
+ const batch = pendingEffects;
190
+ pendingEffects = [];
191
+ for (let i = 0; i < batch.length; i++) {
192
+ const e = batch[i];
193
+ e._pending = false;
194
+ if (!e.disposed && !e._onNotify) _runEffect(e);
195
+ }
196
+ iterations++;
197
+ }
198
+ if (iterations >= 100) {
199
+ if (__DEV__) {
200
+ const remaining = pendingEffects.slice(0, 3);
201
+ const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
202
+ console.warn(
203
+ `[what] Possible infinite effect loop detected (100 iterations). ` +
204
+ `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
205
+ `Use untrack() to read signals without subscribing. ` +
206
+ `Looping effects: ${effectNames.join(', ')}`
207
+ );
208
+ } else {
209
+ console.warn('[what] Possible infinite effect loop detected');
210
+ }
211
+ for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
212
+ pendingEffects.length = 0;
213
+ }
214
+ }
272
215
  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));
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.
216
+ let value;
217
+ const subs = new Set();
218
+ const e = _createEffect(() => {
219
+ const next = fn();
220
+ if (!Object.is(value, next)) {
221
+ value = next;
222
+ notify(subs);
223
+ }
224
+ });
225
+ _runEffect(e);
226
+ if (currentRoot) {
227
+ currentRoot.disposals.push(() => _disposeEffect(e));
228
+ }
229
+ function read() {
230
+ if (currentEffect) {
231
+ subs.add(currentEffect);
232
+ currentEffect.deps.push(subs);
233
+ }
234
+ return value;
235
+ }
236
+ read._signal = true;
237
+ read.peek = () => value;
238
+ return read;
239
+ }
306
240
  export function flushSync() {
307
- microtaskScheduled = false;
308
- flush();
241
+ microtaskScheduled = false;
242
+ flush();
309
243
  }
310
-
311
- // --- Untrack ---
312
- // Read signals without subscribing
313
244
  export function untrack(fn) {
314
- const prev = currentEffect;
315
- currentEffect = null;
316
- try {
317
- return fn();
318
- } finally {
319
- currentEffect = prev;
320
- }
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.
245
+ const prev = currentEffect;
246
+ currentEffect = null;
247
+ try {
248
+ return fn();
249
+ } finally {
250
+ currentEffect = prev;
251
+ }
252
+ }
326
253
  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
- }
254
+ const prevRoot = currentRoot;
255
+ const root = { disposals: [], owner: currentRoot };
256
+ currentRoot = root;
257
+ try {
258
+ const dispose = () => {
259
+ for (let i = root.disposals.length - 1; i >= 0; i--) {
260
+ root.disposals[i]();
261
+ }
262
+ root.disposals.length = 0;
263
+ };
264
+ return fn(dispose);
265
+ } finally {
266
+ currentRoot = prevRoot;
341
267
  }
268
+ }