what-core 0.2.0 → 0.3.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/animation.js CHANGED
@@ -2,8 +2,17 @@
2
2
  // Springs, tweens, gestures, and transition helpers
3
3
 
4
4
  import { signal, effect, untrack, batch } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
  import { scheduleRead, scheduleWrite } from './scheduler.js';
6
7
 
8
+ // Create an effect scoped to the current component's lifecycle
9
+ function scopedEffect(fn) {
10
+ const ctx = getCurrentComponent?.();
11
+ const dispose = effect(fn);
12
+ if (ctx) ctx.effects.push(dispose);
13
+ return dispose;
14
+ }
15
+
7
16
  // --- Spring Animation ---
8
17
  // Physics-based animation with natural feel
9
18
 
@@ -388,14 +397,14 @@ export function useGesture(element, handlers = {}) {
388
397
  // Attach listeners
389
398
  if (typeof element === 'function') {
390
399
  // Ref function
391
- effect(() => {
400
+ scopedEffect(() => {
392
401
  const el = untrack(element);
393
402
  if (!el) return;
394
403
  return attachListeners(el);
395
404
  });
396
405
  } else if (element?.current !== undefined) {
397
406
  // Ref object
398
- effect(() => {
407
+ scopedEffect(() => {
399
408
  const el = element.current;
400
409
  if (!el) return;
401
410
  return attachListeners(el);
package/dist/data.js CHANGED
@@ -2,11 +2,21 @@
2
2
  // SWR-like data fetching with caching, revalidation, and optimistic updates
3
3
 
4
4
  import { signal, effect, batch, computed } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
 
6
7
  // Global cache for requests
7
8
  const cache = new Map();
8
9
  const inFlightRequests = new Map();
9
10
 
11
+ // Create an effect scoped to the current component's lifecycle.
12
+ // When the component unmounts, the effect is automatically disposed.
13
+ function scopedEffect(fn) {
14
+ const ctx = getCurrentComponent?.();
15
+ const dispose = effect(fn);
16
+ if (ctx) ctx.effects.push(dispose);
17
+ return dispose;
18
+ }
19
+
10
20
  // --- useFetch Hook ---
11
21
  // Simple fetch with automatic JSON parsing and error handling
12
22
 
@@ -51,7 +61,7 @@ export function useFetch(url, options = {}) {
51
61
  }
52
62
 
53
63
  // Fetch on mount
54
- effect(() => {
64
+ scopedEffect(() => {
55
65
  fetchData();
56
66
  });
57
67
 
@@ -120,13 +130,13 @@ export function useSWR(key, fetcher, options = {}) {
120
130
  }
121
131
 
122
132
  // Initial fetch
123
- effect(() => {
133
+ scopedEffect(() => {
124
134
  revalidate().catch(() => {});
125
135
  });
126
136
 
127
137
  // Revalidate on focus
128
138
  if (revalidateOnFocus && typeof window !== 'undefined') {
129
- effect(() => {
139
+ scopedEffect(() => {
130
140
  const handler = () => {
131
141
  if (document.visibilityState === 'visible') {
132
142
  revalidate().catch(() => {});
@@ -139,7 +149,7 @@ export function useSWR(key, fetcher, options = {}) {
139
149
 
140
150
  // Revalidate on reconnect
141
151
  if (revalidateOnReconnect && typeof window !== 'undefined') {
142
- effect(() => {
152
+ scopedEffect(() => {
143
153
  const handler = () => revalidate().catch(() => {});
144
154
  window.addEventListener('online', handler);
145
155
  return () => window.removeEventListener('online', handler);
@@ -148,7 +158,7 @@ export function useSWR(key, fetcher, options = {}) {
148
158
 
149
159
  // Polling
150
160
  if (refreshInterval > 0) {
151
- effect(() => {
161
+ scopedEffect(() => {
152
162
  const interval = setInterval(() => {
153
163
  revalidate().catch(() => {});
154
164
  }, refreshInterval);
@@ -269,7 +279,7 @@ export function useQuery(options) {
269
279
  }
270
280
 
271
281
  // Initial fetch
272
- effect(() => {
282
+ scopedEffect(() => {
273
283
  if (enabled) {
274
284
  fetch().catch(() => {});
275
285
  }
@@ -277,7 +287,7 @@ export function useQuery(options) {
277
287
 
278
288
  // Refetch on focus
279
289
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
280
- effect(() => {
290
+ scopedEffect(() => {
281
291
  const handler = () => {
282
292
  if (document.visibilityState === 'visible') {
283
293
  fetch().catch(() => {});
@@ -290,7 +300,7 @@ export function useQuery(options) {
290
300
 
291
301
  // Polling
292
302
  if (refetchInterval) {
293
- effect(() => {
303
+ scopedEffect(() => {
294
304
  const interval = setInterval(() => {
295
305
  fetch().catch(() => {});
296
306
  }, refetchInterval);
@@ -368,7 +378,7 @@ export function useInfiniteQuery(options) {
368
378
  }
369
379
 
370
380
  // Initial fetch
371
- effect(() => {
381
+ scopedEffect(() => {
372
382
  fetchPage(initialPageParam).catch(() => {});
373
383
  });
374
384
 
package/dist/dom.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // What Framework - DOM Reconciler
2
2
  // Surgical DOM updates. Diff props, diff children, patch only what changed.
3
+ // Components use <what-c> wrapper elements (display:contents) for clean reconciliation.
3
4
  // No virtual DOM tree kept in memory — we diff against the live DOM.
4
5
 
5
- import { effect, batch, untrack } from './reactive.js';
6
+ import { effect, batch, untrack, signal } from './reactive.js';
6
7
  import { errorBoundaryStack, reportError } from './components.js';
7
8
 
8
9
  // SVG elements that need namespace
@@ -18,25 +19,71 @@ const SVG_ELEMENTS = new Set([
18
19
  ]);
19
20
  const SVG_NS = 'http://www.w3.org/2000/svg';
20
21
 
22
+ // Track all mounted component contexts for disposal
23
+ const mountedComponents = new Set();
24
+
25
+ // Dispose a component: run effect cleanups, hook cleanups, onCleanup callbacks
26
+ function disposeComponent(ctx) {
27
+ if (ctx.disposed) return;
28
+ ctx.disposed = true;
29
+
30
+ // Run useEffect cleanup functions
31
+ for (const hook of ctx.hooks) {
32
+ if (hook && typeof hook === 'object' && 'cleanup' in hook && hook.cleanup) {
33
+ try { hook.cleanup(); } catch (e) { console.error('[what] cleanup error:', e); }
34
+ }
35
+ }
36
+
37
+ // Run onCleanup callbacks
38
+ if (ctx._cleanupCallbacks) {
39
+ for (const fn of ctx._cleanupCallbacks) {
40
+ try { fn(); } catch (e) { console.error('[what] onCleanup error:', e); }
41
+ }
42
+ }
43
+
44
+ // Dispose reactive effects
45
+ for (const dispose of ctx.effects) {
46
+ try { dispose(); } catch (e) { /* effect already disposed */ }
47
+ }
48
+
49
+ mountedComponents.delete(ctx);
50
+ }
51
+
52
+ // Dispose all components attached to a DOM subtree
53
+ function disposeTree(node) {
54
+ if (!node) return;
55
+ if (node._componentCtx) {
56
+ disposeComponent(node._componentCtx);
57
+ }
58
+ if (node.childNodes) {
59
+ for (const child of node.childNodes) {
60
+ disposeTree(child);
61
+ }
62
+ }
63
+ }
64
+
21
65
  // Mount a component tree into a DOM container
22
66
  export function mount(vnode, container) {
23
67
  if (typeof container === 'string') {
24
68
  container = document.querySelector(container);
25
69
  }
70
+ disposeTree(container); // Clean up any previous mount
26
71
  container.textContent = '';
27
72
  const node = createDOM(vnode, container);
28
73
  if (node) container.appendChild(node);
29
74
  return () => {
30
- // Unmount: clean up effects, remove DOM
75
+ disposeTree(container);
31
76
  container.textContent = '';
32
- // Disposal is handled by effect cleanup
33
77
  };
34
78
  }
35
79
 
36
80
  // --- Create DOM from VNode ---
37
81
 
38
82
  function createDOM(vnode, parent, isSvg) {
39
- if (vnode == null || vnode === false || vnode === true) return null;
83
+ // Null/false/true placeholder comment (preserves child indices for reconciliation)
84
+ if (vnode == null || vnode === false || vnode === true) {
85
+ return document.createComment('');
86
+ }
40
87
 
41
88
  // Text
42
89
  if (typeof vnode === 'string' || typeof vnode === 'number') {
@@ -55,7 +102,7 @@ function createDOM(vnode, parent, isSvg) {
55
102
 
56
103
  // Component
57
104
  if (typeof vnode.tag === 'function') {
58
- return createComponent(vnode, parent);
105
+ return createComponent(vnode, parent, isSvg);
59
106
  }
60
107
 
61
108
  // Detect SVG context: either we're already in SVG, or this tag is an SVG element
@@ -85,7 +132,11 @@ export function getCurrentComponent() {
85
132
  return componentStack[componentStack.length - 1];
86
133
  }
87
134
 
88
- function createComponent(vnode, parent) {
135
+ export function getComponentStack() {
136
+ return componentStack;
137
+ }
138
+
139
+ function createComponent(vnode, parent, isSvg) {
89
140
  const { tag: Component, props, children } = vnode;
90
141
 
91
142
  // Handle special boundary components
@@ -104,14 +155,29 @@ function createComponent(vnode, parent) {
104
155
  cleanups: [],
105
156
  mounted: false,
106
157
  disposed: false,
158
+ Component, // Store for identity check in patchNode
107
159
  };
108
160
 
109
- // Create a marker for this component's position
110
- const marker = document.createComment(`w:${Component.name || 'anon'}`);
161
+ // Wrapper element: <what-c display:contents> for HTML, <g> for SVG
162
+ let wrapper;
163
+ if (isSvg) {
164
+ wrapper = document.createElementNS(SVG_NS, 'g');
165
+ } else {
166
+ wrapper = document.createElement('what-c');
167
+ wrapper.style.display = 'contents';
168
+ }
169
+ wrapper._componentCtx = ctx;
170
+ wrapper._isSvg = !!isSvg;
171
+ ctx._wrapper = wrapper;
172
+
173
+ // Track for disposal
174
+ mountedComponents.add(ctx);
111
175
 
112
- // Reactive render: re-renders when signals used inside change
113
- let currentNodes = [];
176
+ // Props signal for reactive updates from parent
177
+ const propsSignal = signal({ ...props, children });
178
+ ctx._propsSignal = propsSignal;
114
179
 
180
+ // Reactive render: re-renders when signals used inside change
115
181
  const dispose = effect(() => {
116
182
  if (ctx.disposed) return;
117
183
  ctx.hookIndex = 0;
@@ -120,12 +186,10 @@ function createComponent(vnode, parent) {
120
186
 
121
187
  let result;
122
188
  try {
123
- result = Component({ ...props, children });
189
+ result = Component(propsSignal());
124
190
  } catch (error) {
125
191
  componentStack.pop();
126
- // Try to report to nearest error boundary
127
192
  if (!reportError(error)) {
128
- // No boundary, re-throw
129
193
  console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
130
194
  throw error;
131
195
  }
@@ -139,28 +203,29 @@ function createComponent(vnode, parent) {
139
203
  if (!ctx.mounted) {
140
204
  // Initial mount
141
205
  ctx.mounted = true;
206
+
207
+ // Run onMount callbacks after DOM is ready
208
+ if (ctx._mountCallbacks) {
209
+ queueMicrotask(() => {
210
+ if (ctx.disposed) return;
211
+ for (const fn of ctx._mountCallbacks) {
212
+ try { fn(); } catch (e) { console.error('[what] onMount error:', e); }
213
+ }
214
+ });
215
+ }
216
+
142
217
  for (const v of vnodes) {
143
- const node = createDOM(v, parent);
144
- if (node) {
145
- currentNodes.push(node);
146
- }
218
+ const node = createDOM(v, wrapper, isSvg);
219
+ if (node) wrapper.appendChild(node);
147
220
  }
148
221
  } else {
149
- // Update: reconcile
150
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
222
+ // Update: reconcile children inside wrapper
223
+ reconcileChildren(wrapper, vnodes);
151
224
  }
152
225
  });
153
226
 
154
227
  ctx.effects.push(dispose);
155
-
156
- // Return a fragment with marker + rendered nodes
157
- const frag = document.createDocumentFragment();
158
- frag.appendChild(marker);
159
- for (const node of currentNodes) {
160
- frag.appendChild(node);
161
- }
162
-
163
- return frag;
228
+ return wrapper;
164
229
  }
165
230
 
166
231
  // Error boundary component handler
@@ -168,51 +233,35 @@ function createErrorBoundary(vnode, parent) {
168
233
  const { errorState, handleError, fallback, reset } = vnode.props;
169
234
  const children = vnode.children;
170
235
 
171
- const marker = document.createComment('w:errorBoundary');
172
- let currentNodes = [];
173
-
174
- // Register this boundary
175
- const boundary = { handleError };
236
+ const wrapper = document.createElement('what-c');
237
+ wrapper.style.display = 'contents';
176
238
 
177
239
  const dispose = effect(() => {
178
240
  const error = errorState();
179
241
 
180
- // Push boundary to stack so children can find it
181
- errorBoundaryStack.push(boundary);
242
+ errorBoundaryStack.push({ handleError });
182
243
 
183
244
  let vnodes;
184
245
  if (error) {
185
- // Render fallback
186
- if (typeof fallback === 'function') {
187
- vnodes = [fallback({ error, reset })];
188
- } else {
189
- vnodes = [fallback];
190
- }
246
+ vnodes = typeof fallback === 'function' ? [fallback({ error, reset })] : [fallback];
191
247
  } else {
192
248
  vnodes = children;
193
249
  }
194
250
 
195
251
  errorBoundaryStack.pop();
196
-
197
252
  vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
198
253
 
199
- if (currentNodes.length === 0) {
200
- // Initial mount
254
+ if (wrapper.childNodes.length === 0) {
201
255
  for (const v of vnodes) {
202
- const node = createDOM(v, parent);
203
- if (node) currentNodes.push(node);
256
+ const node = createDOM(v, wrapper);
257
+ if (node) wrapper.appendChild(node);
204
258
  }
205
259
  } else {
206
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
260
+ reconcileChildren(wrapper, vnodes);
207
261
  }
208
262
  });
209
263
 
210
- const frag = document.createDocumentFragment();
211
- frag.appendChild(marker);
212
- for (const node of currentNodes) {
213
- frag.appendChild(node);
214
- }
215
- return frag;
264
+ return wrapper;
216
265
  }
217
266
 
218
267
  // Suspense boundary component handler
@@ -220,30 +269,25 @@ function createSuspenseBoundary(vnode, parent) {
220
269
  const { boundary, fallback, loading } = vnode.props;
221
270
  const children = vnode.children;
222
271
 
223
- const marker = document.createComment('w:suspense');
224
- let currentNodes = [];
272
+ const wrapper = document.createElement('what-c');
273
+ wrapper.style.display = 'contents';
225
274
 
226
275
  const dispose = effect(() => {
227
276
  const isLoading = loading();
228
277
  const vnodes = isLoading ? [fallback] : children;
229
278
  const normalized = Array.isArray(vnodes) ? vnodes : [vnodes];
230
279
 
231
- if (currentNodes.length === 0) {
280
+ if (wrapper.childNodes.length === 0) {
232
281
  for (const v of normalized) {
233
- const node = createDOM(v, parent);
234
- if (node) currentNodes.push(node);
282
+ const node = createDOM(v, wrapper);
283
+ if (node) wrapper.appendChild(node);
235
284
  }
236
285
  } else {
237
- reconcile(marker.parentNode, currentNodes, normalized, marker);
286
+ reconcileChildren(wrapper, normalized);
238
287
  }
239
288
  });
240
289
 
241
- const frag = document.createDocumentFragment();
242
- frag.appendChild(marker);
243
- for (const node of currentNodes) {
244
- frag.appendChild(node);
245
- }
246
- return frag;
290
+ return wrapper;
247
291
  }
248
292
 
249
293
  // --- Reconciliation ---
@@ -253,7 +297,6 @@ function createSuspenseBoundary(vnode, parent) {
253
297
  function reconcile(parent, oldNodes, newVNodes, beforeMarker) {
254
298
  if (!parent) return;
255
299
 
256
- // Check if we have keyed children
257
300
  const hasKeys = newVNodes.some(v => v && typeof v === 'object' && v.key != null);
258
301
 
259
302
  if (hasKeys) {
@@ -275,6 +318,7 @@ function reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker) {
275
318
  if (i >= newVNodes.length) {
276
319
  // Remove extra old nodes
277
320
  if (oldNode && oldNode.parentNode) {
321
+ disposeTree(oldNode);
278
322
  oldNode.parentNode.removeChild(oldNode);
279
323
  }
280
324
  continue;
@@ -333,6 +377,7 @@ function reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker) {
333
377
  // Remove nodes that aren't reused
334
378
  for (let i = 0; i < oldNodes.length; i++) {
335
379
  if (!reused.has(i) && oldNodes[i]?.parentNode) {
380
+ disposeTree(oldNodes[i]);
336
381
  oldNodes[i].parentNode.removeChild(oldNodes[i]);
337
382
  }
338
383
  }
@@ -435,10 +480,17 @@ function getInsertionRef(nodes, marker) {
435
480
  }
436
481
 
437
482
  function patchNode(parent, domNode, vnode) {
438
- // Null/removed
483
+ // Null/removed → keep placeholder or replace with one
439
484
  if (vnode == null || vnode === false || vnode === true) {
440
- if (domNode && domNode.parentNode) domNode.parentNode.removeChild(domNode);
441
- return null;
485
+ if (domNode && domNode.nodeType === 8 && !domNode._componentCtx) {
486
+ return domNode; // already a placeholder comment
487
+ }
488
+ const placeholder = document.createComment('');
489
+ if (domNode && domNode.parentNode) {
490
+ disposeTree(domNode);
491
+ parent.replaceChild(placeholder, domNode);
492
+ }
493
+ return placeholder;
442
494
  }
443
495
 
444
496
  // Text
@@ -449,6 +501,7 @@ function patchNode(parent, domNode, vnode) {
449
501
  return domNode;
450
502
  }
451
503
  const newNode = document.createTextNode(text);
504
+ disposeTree(domNode);
452
505
  parent.replaceChild(newNode, domNode);
453
506
  return newNode;
454
507
  }
@@ -461,13 +514,22 @@ function patchNode(parent, domNode, vnode) {
461
514
  const node = createDOM(v, parent);
462
515
  if (node) frag.appendChild(node);
463
516
  }
517
+ disposeTree(domNode);
464
518
  parent.replaceChild(frag, domNode);
465
519
  return frag;
466
520
  }
467
521
 
468
522
  // Component
469
523
  if (typeof vnode.tag === 'function') {
470
- // Re-create component (future: memoize + diff props)
524
+ // Check if old node is a component wrapper for the same component
525
+ if (domNode._componentCtx && !domNode._componentCtx.disposed
526
+ && domNode._componentCtx.Component === vnode.tag) {
527
+ // Same component — update props reactively, let its effect re-render
528
+ domNode._componentCtx._propsSignal.set({ ...vnode.props, children: vnode.children });
529
+ return domNode;
530
+ }
531
+ // Different component or not a component — dispose old, create new
532
+ disposeTree(domNode);
471
533
  const node = createComponent(vnode, parent);
472
534
  parent.replaceChild(node, domNode);
473
535
  return node;
@@ -484,6 +546,7 @@ function patchNode(parent, domNode, vnode) {
484
546
 
485
547
  // Different tag: replace entirely
486
548
  const newNode = createDOM(vnode, parent);
549
+ disposeTree(domNode);
487
550
  parent.replaceChild(newNode, domNode);
488
551
  return newNode;
489
552
  }
@@ -505,6 +568,7 @@ function reconcileChildren(parent, newChildVNodes) {
505
568
  if (i >= newChildVNodes.length) {
506
569
  // Remove extra
507
570
  if (oldChildren[i]?.parentNode) {
571
+ disposeTree(oldChildren[i]);
508
572
  parent.removeChild(oldChildren[i]);
509
573
  }
510
574
  continue;
package/dist/hooks.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // React-familiar hooks backed by signals. Zero overhead when deps don't change.
3
3
 
4
4
  import { signal, computed, effect, batch, untrack } from './reactive.js';
5
- import { getCurrentComponent } from './dom.js';
5
+ import { getCurrentComponent, getComponentStack as _getComponentStack } from './dom.js';
6
6
 
7
7
  function getCtx() {
8
8
  const ctx = getCurrentComponent();
@@ -128,24 +128,36 @@ export function useRef(initial) {
128
128
  }
129
129
 
130
130
  // --- useContext ---
131
- // Read from a context created by createContext().
131
+ // Read from the nearest Provider in the component tree, or the default value.
132
132
 
133
133
  export function useContext(context) {
134
- return context._value;
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];
138
+ if (ctx._contextValues && ctx._contextValues.has(context)) {
139
+ return ctx._contextValues.get(context);
140
+ }
141
+ }
142
+ return context._defaultValue;
135
143
  }
136
144
 
137
145
  // --- createContext ---
138
- // Simple context: set a default, override with Provider component.
146
+ // Tree-scoped context: Provider sets value for its subtree only.
147
+ // Multiple providers can coexist — each subtree sees its own value.
139
148
 
140
149
  export function createContext(defaultValue) {
141
- const ctx = {
142
- _value: defaultValue,
150
+ const context = {
151
+ _defaultValue: defaultValue,
143
152
  Provider: ({ value, children }) => {
144
- ctx._value = value;
153
+ // Store context value on the current component's context
154
+ const ctx = getCtx();
155
+ if (!ctx._contextValues) ctx._contextValues = new Map();
156
+ ctx._contextValues.set(context, value);
145
157
  return children;
146
158
  },
147
159
  };
148
- return ctx;
160
+ return context;
149
161
  }
150
162
 
151
163
  // --- useReducer ---
@@ -173,7 +185,7 @@ export function useReducer(reducer, initialState, init) {
173
185
 
174
186
  export function onMount(fn) {
175
187
  const ctx = getCtx();
176
- if (!ctx._mounted) {
188
+ if (!ctx.mounted) {
177
189
  ctx._mountCallbacks = ctx._mountCallbacks || [];
178
190
  ctx._mountCallbacks.push(fn);
179
191
  }
@@ -214,7 +226,7 @@ export function createResource(fetcher, options = {}) {
214
226
  loading.set(false);
215
227
  }
216
228
  } catch (e) {
217
- if (currentFetch === fetcher) {
229
+ if (currentFetch === fetchPromise) {
218
230
  error.set(e);
219
231
  loading.set(false);
220
232
  }
package/dist/reactive.js CHANGED
@@ -109,10 +109,19 @@ function _createEffect(fn, opts = {}) {
109
109
  function _runEffect(e) {
110
110
  if (e.disposed) return;
111
111
  cleanup(e);
112
+ // Run effect cleanup from previous run
113
+ if (e._cleanup) {
114
+ try { e._cleanup(); } catch (err) { /* cleanup error */ }
115
+ e._cleanup = null;
116
+ }
112
117
  const prev = currentEffect;
113
118
  currentEffect = e;
114
119
  try {
115
- e.fn();
120
+ const result = e.fn();
121
+ // Capture cleanup function if returned
122
+ if (typeof result === 'function') {
123
+ e._cleanup = result;
124
+ }
116
125
  } finally {
117
126
  currentEffect = prev;
118
127
  }
@@ -121,6 +130,11 @@ function _runEffect(e) {
121
130
  function _disposeEffect(e) {
122
131
  e.disposed = true;
123
132
  cleanup(e);
133
+ // Run cleanup on dispose
134
+ if (e._cleanup) {
135
+ try { e._cleanup(); } catch (err) { /* cleanup error */ }
136
+ e._cleanup = null;
137
+ }
124
138
  }
125
139
 
126
140
  function cleanup(e) {
package/dist/store.js CHANGED
@@ -77,6 +77,7 @@ export function createStore(definition) {
77
77
  const proxy = new Proxy({}, {
78
78
  get(_, prop) {
79
79
  if (signals[prop]) return signals[prop].peek();
80
+ if (computeds[prop]) return computeds[prop].peek();
80
81
  return undefined;
81
82
  },
82
83
  set(_, prop, val) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "What Framework - The closest framework to vanilla JS",
5
5
  "type": "module",
6
6
  "main": "dist/what.js",
package/src/animation.js CHANGED
@@ -2,8 +2,17 @@
2
2
  // Springs, tweens, gestures, and transition helpers
3
3
 
4
4
  import { signal, effect, untrack, batch } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
  import { scheduleRead, scheduleWrite } from './scheduler.js';
6
7
 
8
+ // Create an effect scoped to the current component's lifecycle
9
+ function scopedEffect(fn) {
10
+ const ctx = getCurrentComponent?.();
11
+ const dispose = effect(fn);
12
+ if (ctx) ctx.effects.push(dispose);
13
+ return dispose;
14
+ }
15
+
7
16
  // --- Spring Animation ---
8
17
  // Physics-based animation with natural feel
9
18
 
@@ -388,14 +397,14 @@ export function useGesture(element, handlers = {}) {
388
397
  // Attach listeners
389
398
  if (typeof element === 'function') {
390
399
  // Ref function
391
- effect(() => {
400
+ scopedEffect(() => {
392
401
  const el = untrack(element);
393
402
  if (!el) return;
394
403
  return attachListeners(el);
395
404
  });
396
405
  } else if (element?.current !== undefined) {
397
406
  // Ref object
398
- effect(() => {
407
+ scopedEffect(() => {
399
408
  const el = element.current;
400
409
  if (!el) return;
401
410
  return attachListeners(el);
package/src/data.js CHANGED
@@ -2,11 +2,21 @@
2
2
  // SWR-like data fetching with caching, revalidation, and optimistic updates
3
3
 
4
4
  import { signal, effect, batch, computed } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
 
6
7
  // Global cache for requests
7
8
  const cache = new Map();
8
9
  const inFlightRequests = new Map();
9
10
 
11
+ // Create an effect scoped to the current component's lifecycle.
12
+ // When the component unmounts, the effect is automatically disposed.
13
+ function scopedEffect(fn) {
14
+ const ctx = getCurrentComponent?.();
15
+ const dispose = effect(fn);
16
+ if (ctx) ctx.effects.push(dispose);
17
+ return dispose;
18
+ }
19
+
10
20
  // --- useFetch Hook ---
11
21
  // Simple fetch with automatic JSON parsing and error handling
12
22
 
@@ -51,7 +61,7 @@ export function useFetch(url, options = {}) {
51
61
  }
52
62
 
53
63
  // Fetch on mount
54
- effect(() => {
64
+ scopedEffect(() => {
55
65
  fetchData();
56
66
  });
57
67
 
@@ -120,13 +130,13 @@ export function useSWR(key, fetcher, options = {}) {
120
130
  }
121
131
 
122
132
  // Initial fetch
123
- effect(() => {
133
+ scopedEffect(() => {
124
134
  revalidate().catch(() => {});
125
135
  });
126
136
 
127
137
  // Revalidate on focus
128
138
  if (revalidateOnFocus && typeof window !== 'undefined') {
129
- effect(() => {
139
+ scopedEffect(() => {
130
140
  const handler = () => {
131
141
  if (document.visibilityState === 'visible') {
132
142
  revalidate().catch(() => {});
@@ -139,7 +149,7 @@ export function useSWR(key, fetcher, options = {}) {
139
149
 
140
150
  // Revalidate on reconnect
141
151
  if (revalidateOnReconnect && typeof window !== 'undefined') {
142
- effect(() => {
152
+ scopedEffect(() => {
143
153
  const handler = () => revalidate().catch(() => {});
144
154
  window.addEventListener('online', handler);
145
155
  return () => window.removeEventListener('online', handler);
@@ -148,7 +158,7 @@ export function useSWR(key, fetcher, options = {}) {
148
158
 
149
159
  // Polling
150
160
  if (refreshInterval > 0) {
151
- effect(() => {
161
+ scopedEffect(() => {
152
162
  const interval = setInterval(() => {
153
163
  revalidate().catch(() => {});
154
164
  }, refreshInterval);
@@ -269,7 +279,7 @@ export function useQuery(options) {
269
279
  }
270
280
 
271
281
  // Initial fetch
272
- effect(() => {
282
+ scopedEffect(() => {
273
283
  if (enabled) {
274
284
  fetch().catch(() => {});
275
285
  }
@@ -277,7 +287,7 @@ export function useQuery(options) {
277
287
 
278
288
  // Refetch on focus
279
289
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
280
- effect(() => {
290
+ scopedEffect(() => {
281
291
  const handler = () => {
282
292
  if (document.visibilityState === 'visible') {
283
293
  fetch().catch(() => {});
@@ -290,7 +300,7 @@ export function useQuery(options) {
290
300
 
291
301
  // Polling
292
302
  if (refetchInterval) {
293
- effect(() => {
303
+ scopedEffect(() => {
294
304
  const interval = setInterval(() => {
295
305
  fetch().catch(() => {});
296
306
  }, refetchInterval);
@@ -368,7 +378,7 @@ export function useInfiniteQuery(options) {
368
378
  }
369
379
 
370
380
  // Initial fetch
371
- effect(() => {
381
+ scopedEffect(() => {
372
382
  fetchPage(initialPageParam).catch(() => {});
373
383
  });
374
384
 
package/src/dom.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // What Framework - DOM Reconciler
2
2
  // Surgical DOM updates. Diff props, diff children, patch only what changed.
3
+ // Components use <what-c> wrapper elements (display:contents) for clean reconciliation.
3
4
  // No virtual DOM tree kept in memory — we diff against the live DOM.
4
5
 
5
- import { effect, batch, untrack } from './reactive.js';
6
+ import { effect, batch, untrack, signal } from './reactive.js';
6
7
  import { errorBoundaryStack, reportError } from './components.js';
7
8
 
8
9
  // SVG elements that need namespace
@@ -18,25 +19,71 @@ const SVG_ELEMENTS = new Set([
18
19
  ]);
19
20
  const SVG_NS = 'http://www.w3.org/2000/svg';
20
21
 
22
+ // Track all mounted component contexts for disposal
23
+ const mountedComponents = new Set();
24
+
25
+ // Dispose a component: run effect cleanups, hook cleanups, onCleanup callbacks
26
+ function disposeComponent(ctx) {
27
+ if (ctx.disposed) return;
28
+ ctx.disposed = true;
29
+
30
+ // Run useEffect cleanup functions
31
+ for (const hook of ctx.hooks) {
32
+ if (hook && typeof hook === 'object' && 'cleanup' in hook && hook.cleanup) {
33
+ try { hook.cleanup(); } catch (e) { console.error('[what] cleanup error:', e); }
34
+ }
35
+ }
36
+
37
+ // Run onCleanup callbacks
38
+ if (ctx._cleanupCallbacks) {
39
+ for (const fn of ctx._cleanupCallbacks) {
40
+ try { fn(); } catch (e) { console.error('[what] onCleanup error:', e); }
41
+ }
42
+ }
43
+
44
+ // Dispose reactive effects
45
+ for (const dispose of ctx.effects) {
46
+ try { dispose(); } catch (e) { /* effect already disposed */ }
47
+ }
48
+
49
+ mountedComponents.delete(ctx);
50
+ }
51
+
52
+ // Dispose all components attached to a DOM subtree
53
+ function disposeTree(node) {
54
+ if (!node) return;
55
+ if (node._componentCtx) {
56
+ disposeComponent(node._componentCtx);
57
+ }
58
+ if (node.childNodes) {
59
+ for (const child of node.childNodes) {
60
+ disposeTree(child);
61
+ }
62
+ }
63
+ }
64
+
21
65
  // Mount a component tree into a DOM container
22
66
  export function mount(vnode, container) {
23
67
  if (typeof container === 'string') {
24
68
  container = document.querySelector(container);
25
69
  }
70
+ disposeTree(container); // Clean up any previous mount
26
71
  container.textContent = '';
27
72
  const node = createDOM(vnode, container);
28
73
  if (node) container.appendChild(node);
29
74
  return () => {
30
- // Unmount: clean up effects, remove DOM
75
+ disposeTree(container);
31
76
  container.textContent = '';
32
- // Disposal is handled by effect cleanup
33
77
  };
34
78
  }
35
79
 
36
80
  // --- Create DOM from VNode ---
37
81
 
38
82
  function createDOM(vnode, parent, isSvg) {
39
- if (vnode == null || vnode === false || vnode === true) return null;
83
+ // Null/false/true placeholder comment (preserves child indices for reconciliation)
84
+ if (vnode == null || vnode === false || vnode === true) {
85
+ return document.createComment('');
86
+ }
40
87
 
41
88
  // Text
42
89
  if (typeof vnode === 'string' || typeof vnode === 'number') {
@@ -55,7 +102,7 @@ function createDOM(vnode, parent, isSvg) {
55
102
 
56
103
  // Component
57
104
  if (typeof vnode.tag === 'function') {
58
- return createComponent(vnode, parent);
105
+ return createComponent(vnode, parent, isSvg);
59
106
  }
60
107
 
61
108
  // Detect SVG context: either we're already in SVG, or this tag is an SVG element
@@ -85,7 +132,11 @@ export function getCurrentComponent() {
85
132
  return componentStack[componentStack.length - 1];
86
133
  }
87
134
 
88
- function createComponent(vnode, parent) {
135
+ export function getComponentStack() {
136
+ return componentStack;
137
+ }
138
+
139
+ function createComponent(vnode, parent, isSvg) {
89
140
  const { tag: Component, props, children } = vnode;
90
141
 
91
142
  // Handle special boundary components
@@ -104,14 +155,29 @@ function createComponent(vnode, parent) {
104
155
  cleanups: [],
105
156
  mounted: false,
106
157
  disposed: false,
158
+ Component, // Store for identity check in patchNode
107
159
  };
108
160
 
109
- // Create a marker for this component's position
110
- const marker = document.createComment(`w:${Component.name || 'anon'}`);
161
+ // Wrapper element: <what-c display:contents> for HTML, <g> for SVG
162
+ let wrapper;
163
+ if (isSvg) {
164
+ wrapper = document.createElementNS(SVG_NS, 'g');
165
+ } else {
166
+ wrapper = document.createElement('what-c');
167
+ wrapper.style.display = 'contents';
168
+ }
169
+ wrapper._componentCtx = ctx;
170
+ wrapper._isSvg = !!isSvg;
171
+ ctx._wrapper = wrapper;
172
+
173
+ // Track for disposal
174
+ mountedComponents.add(ctx);
111
175
 
112
- // Reactive render: re-renders when signals used inside change
113
- let currentNodes = [];
176
+ // Props signal for reactive updates from parent
177
+ const propsSignal = signal({ ...props, children });
178
+ ctx._propsSignal = propsSignal;
114
179
 
180
+ // Reactive render: re-renders when signals used inside change
115
181
  const dispose = effect(() => {
116
182
  if (ctx.disposed) return;
117
183
  ctx.hookIndex = 0;
@@ -120,12 +186,10 @@ function createComponent(vnode, parent) {
120
186
 
121
187
  let result;
122
188
  try {
123
- result = Component({ ...props, children });
189
+ result = Component(propsSignal());
124
190
  } catch (error) {
125
191
  componentStack.pop();
126
- // Try to report to nearest error boundary
127
192
  if (!reportError(error)) {
128
- // No boundary, re-throw
129
193
  console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
130
194
  throw error;
131
195
  }
@@ -139,28 +203,29 @@ function createComponent(vnode, parent) {
139
203
  if (!ctx.mounted) {
140
204
  // Initial mount
141
205
  ctx.mounted = true;
206
+
207
+ // Run onMount callbacks after DOM is ready
208
+ if (ctx._mountCallbacks) {
209
+ queueMicrotask(() => {
210
+ if (ctx.disposed) return;
211
+ for (const fn of ctx._mountCallbacks) {
212
+ try { fn(); } catch (e) { console.error('[what] onMount error:', e); }
213
+ }
214
+ });
215
+ }
216
+
142
217
  for (const v of vnodes) {
143
- const node = createDOM(v, parent);
144
- if (node) {
145
- currentNodes.push(node);
146
- }
218
+ const node = createDOM(v, wrapper, isSvg);
219
+ if (node) wrapper.appendChild(node);
147
220
  }
148
221
  } else {
149
- // Update: reconcile
150
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
222
+ // Update: reconcile children inside wrapper
223
+ reconcileChildren(wrapper, vnodes);
151
224
  }
152
225
  });
153
226
 
154
227
  ctx.effects.push(dispose);
155
-
156
- // Return a fragment with marker + rendered nodes
157
- const frag = document.createDocumentFragment();
158
- frag.appendChild(marker);
159
- for (const node of currentNodes) {
160
- frag.appendChild(node);
161
- }
162
-
163
- return frag;
228
+ return wrapper;
164
229
  }
165
230
 
166
231
  // Error boundary component handler
@@ -168,51 +233,35 @@ function createErrorBoundary(vnode, parent) {
168
233
  const { errorState, handleError, fallback, reset } = vnode.props;
169
234
  const children = vnode.children;
170
235
 
171
- const marker = document.createComment('w:errorBoundary');
172
- let currentNodes = [];
173
-
174
- // Register this boundary
175
- const boundary = { handleError };
236
+ const wrapper = document.createElement('what-c');
237
+ wrapper.style.display = 'contents';
176
238
 
177
239
  const dispose = effect(() => {
178
240
  const error = errorState();
179
241
 
180
- // Push boundary to stack so children can find it
181
- errorBoundaryStack.push(boundary);
242
+ errorBoundaryStack.push({ handleError });
182
243
 
183
244
  let vnodes;
184
245
  if (error) {
185
- // Render fallback
186
- if (typeof fallback === 'function') {
187
- vnodes = [fallback({ error, reset })];
188
- } else {
189
- vnodes = [fallback];
190
- }
246
+ vnodes = typeof fallback === 'function' ? [fallback({ error, reset })] : [fallback];
191
247
  } else {
192
248
  vnodes = children;
193
249
  }
194
250
 
195
251
  errorBoundaryStack.pop();
196
-
197
252
  vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
198
253
 
199
- if (currentNodes.length === 0) {
200
- // Initial mount
254
+ if (wrapper.childNodes.length === 0) {
201
255
  for (const v of vnodes) {
202
- const node = createDOM(v, parent);
203
- if (node) currentNodes.push(node);
256
+ const node = createDOM(v, wrapper);
257
+ if (node) wrapper.appendChild(node);
204
258
  }
205
259
  } else {
206
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
260
+ reconcileChildren(wrapper, vnodes);
207
261
  }
208
262
  });
209
263
 
210
- const frag = document.createDocumentFragment();
211
- frag.appendChild(marker);
212
- for (const node of currentNodes) {
213
- frag.appendChild(node);
214
- }
215
- return frag;
264
+ return wrapper;
216
265
  }
217
266
 
218
267
  // Suspense boundary component handler
@@ -220,30 +269,25 @@ function createSuspenseBoundary(vnode, parent) {
220
269
  const { boundary, fallback, loading } = vnode.props;
221
270
  const children = vnode.children;
222
271
 
223
- const marker = document.createComment('w:suspense');
224
- let currentNodes = [];
272
+ const wrapper = document.createElement('what-c');
273
+ wrapper.style.display = 'contents';
225
274
 
226
275
  const dispose = effect(() => {
227
276
  const isLoading = loading();
228
277
  const vnodes = isLoading ? [fallback] : children;
229
278
  const normalized = Array.isArray(vnodes) ? vnodes : [vnodes];
230
279
 
231
- if (currentNodes.length === 0) {
280
+ if (wrapper.childNodes.length === 0) {
232
281
  for (const v of normalized) {
233
- const node = createDOM(v, parent);
234
- if (node) currentNodes.push(node);
282
+ const node = createDOM(v, wrapper);
283
+ if (node) wrapper.appendChild(node);
235
284
  }
236
285
  } else {
237
- reconcile(marker.parentNode, currentNodes, normalized, marker);
286
+ reconcileChildren(wrapper, normalized);
238
287
  }
239
288
  });
240
289
 
241
- const frag = document.createDocumentFragment();
242
- frag.appendChild(marker);
243
- for (const node of currentNodes) {
244
- frag.appendChild(node);
245
- }
246
- return frag;
290
+ return wrapper;
247
291
  }
248
292
 
249
293
  // --- Reconciliation ---
@@ -253,7 +297,6 @@ function createSuspenseBoundary(vnode, parent) {
253
297
  function reconcile(parent, oldNodes, newVNodes, beforeMarker) {
254
298
  if (!parent) return;
255
299
 
256
- // Check if we have keyed children
257
300
  const hasKeys = newVNodes.some(v => v && typeof v === 'object' && v.key != null);
258
301
 
259
302
  if (hasKeys) {
@@ -275,6 +318,7 @@ function reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker) {
275
318
  if (i >= newVNodes.length) {
276
319
  // Remove extra old nodes
277
320
  if (oldNode && oldNode.parentNode) {
321
+ disposeTree(oldNode);
278
322
  oldNode.parentNode.removeChild(oldNode);
279
323
  }
280
324
  continue;
@@ -333,6 +377,7 @@ function reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker) {
333
377
  // Remove nodes that aren't reused
334
378
  for (let i = 0; i < oldNodes.length; i++) {
335
379
  if (!reused.has(i) && oldNodes[i]?.parentNode) {
380
+ disposeTree(oldNodes[i]);
336
381
  oldNodes[i].parentNode.removeChild(oldNodes[i]);
337
382
  }
338
383
  }
@@ -435,10 +480,17 @@ function getInsertionRef(nodes, marker) {
435
480
  }
436
481
 
437
482
  function patchNode(parent, domNode, vnode) {
438
- // Null/removed
483
+ // Null/removed → keep placeholder or replace with one
439
484
  if (vnode == null || vnode === false || vnode === true) {
440
- if (domNode && domNode.parentNode) domNode.parentNode.removeChild(domNode);
441
- return null;
485
+ if (domNode && domNode.nodeType === 8 && !domNode._componentCtx) {
486
+ return domNode; // already a placeholder comment
487
+ }
488
+ const placeholder = document.createComment('');
489
+ if (domNode && domNode.parentNode) {
490
+ disposeTree(domNode);
491
+ parent.replaceChild(placeholder, domNode);
492
+ }
493
+ return placeholder;
442
494
  }
443
495
 
444
496
  // Text
@@ -449,6 +501,7 @@ function patchNode(parent, domNode, vnode) {
449
501
  return domNode;
450
502
  }
451
503
  const newNode = document.createTextNode(text);
504
+ disposeTree(domNode);
452
505
  parent.replaceChild(newNode, domNode);
453
506
  return newNode;
454
507
  }
@@ -461,13 +514,22 @@ function patchNode(parent, domNode, vnode) {
461
514
  const node = createDOM(v, parent);
462
515
  if (node) frag.appendChild(node);
463
516
  }
517
+ disposeTree(domNode);
464
518
  parent.replaceChild(frag, domNode);
465
519
  return frag;
466
520
  }
467
521
 
468
522
  // Component
469
523
  if (typeof vnode.tag === 'function') {
470
- // Re-create component (future: memoize + diff props)
524
+ // Check if old node is a component wrapper for the same component
525
+ if (domNode._componentCtx && !domNode._componentCtx.disposed
526
+ && domNode._componentCtx.Component === vnode.tag) {
527
+ // Same component — update props reactively, let its effect re-render
528
+ domNode._componentCtx._propsSignal.set({ ...vnode.props, children: vnode.children });
529
+ return domNode;
530
+ }
531
+ // Different component or not a component — dispose old, create new
532
+ disposeTree(domNode);
471
533
  const node = createComponent(vnode, parent);
472
534
  parent.replaceChild(node, domNode);
473
535
  return node;
@@ -484,6 +546,7 @@ function patchNode(parent, domNode, vnode) {
484
546
 
485
547
  // Different tag: replace entirely
486
548
  const newNode = createDOM(vnode, parent);
549
+ disposeTree(domNode);
487
550
  parent.replaceChild(newNode, domNode);
488
551
  return newNode;
489
552
  }
@@ -505,6 +568,7 @@ function reconcileChildren(parent, newChildVNodes) {
505
568
  if (i >= newChildVNodes.length) {
506
569
  // Remove extra
507
570
  if (oldChildren[i]?.parentNode) {
571
+ disposeTree(oldChildren[i]);
508
572
  parent.removeChild(oldChildren[i]);
509
573
  }
510
574
  continue;
package/src/hooks.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // React-familiar hooks backed by signals. Zero overhead when deps don't change.
3
3
 
4
4
  import { signal, computed, effect, batch, untrack } from './reactive.js';
5
- import { getCurrentComponent } from './dom.js';
5
+ import { getCurrentComponent, getComponentStack as _getComponentStack } from './dom.js';
6
6
 
7
7
  function getCtx() {
8
8
  const ctx = getCurrentComponent();
@@ -128,24 +128,36 @@ export function useRef(initial) {
128
128
  }
129
129
 
130
130
  // --- useContext ---
131
- // Read from a context created by createContext().
131
+ // Read from the nearest Provider in the component tree, or the default value.
132
132
 
133
133
  export function useContext(context) {
134
- return context._value;
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];
138
+ if (ctx._contextValues && ctx._contextValues.has(context)) {
139
+ return ctx._contextValues.get(context);
140
+ }
141
+ }
142
+ return context._defaultValue;
135
143
  }
136
144
 
137
145
  // --- createContext ---
138
- // Simple context: set a default, override with Provider component.
146
+ // Tree-scoped context: Provider sets value for its subtree only.
147
+ // Multiple providers can coexist — each subtree sees its own value.
139
148
 
140
149
  export function createContext(defaultValue) {
141
- const ctx = {
142
- _value: defaultValue,
150
+ const context = {
151
+ _defaultValue: defaultValue,
143
152
  Provider: ({ value, children }) => {
144
- ctx._value = value;
153
+ // Store context value on the current component's context
154
+ const ctx = getCtx();
155
+ if (!ctx._contextValues) ctx._contextValues = new Map();
156
+ ctx._contextValues.set(context, value);
145
157
  return children;
146
158
  },
147
159
  };
148
- return ctx;
160
+ return context;
149
161
  }
150
162
 
151
163
  // --- useReducer ---
@@ -173,7 +185,7 @@ export function useReducer(reducer, initialState, init) {
173
185
 
174
186
  export function onMount(fn) {
175
187
  const ctx = getCtx();
176
- if (!ctx._mounted) {
188
+ if (!ctx.mounted) {
177
189
  ctx._mountCallbacks = ctx._mountCallbacks || [];
178
190
  ctx._mountCallbacks.push(fn);
179
191
  }
@@ -214,7 +226,7 @@ export function createResource(fetcher, options = {}) {
214
226
  loading.set(false);
215
227
  }
216
228
  } catch (e) {
217
- if (currentFetch === fetcher) {
229
+ if (currentFetch === fetchPromise) {
218
230
  error.set(e);
219
231
  loading.set(false);
220
232
  }
package/src/reactive.js CHANGED
@@ -109,10 +109,19 @@ function _createEffect(fn, opts = {}) {
109
109
  function _runEffect(e) {
110
110
  if (e.disposed) return;
111
111
  cleanup(e);
112
+ // Run effect cleanup from previous run
113
+ if (e._cleanup) {
114
+ try { e._cleanup(); } catch (err) { /* cleanup error */ }
115
+ e._cleanup = null;
116
+ }
112
117
  const prev = currentEffect;
113
118
  currentEffect = e;
114
119
  try {
115
- e.fn();
120
+ const result = e.fn();
121
+ // Capture cleanup function if returned
122
+ if (typeof result === 'function') {
123
+ e._cleanup = result;
124
+ }
116
125
  } finally {
117
126
  currentEffect = prev;
118
127
  }
@@ -121,6 +130,11 @@ function _runEffect(e) {
121
130
  function _disposeEffect(e) {
122
131
  e.disposed = true;
123
132
  cleanup(e);
133
+ // Run cleanup on dispose
134
+ if (e._cleanup) {
135
+ try { e._cleanup(); } catch (err) { /* cleanup error */ }
136
+ e._cleanup = null;
137
+ }
124
138
  }
125
139
 
126
140
  function cleanup(e) {
package/src/store.js CHANGED
@@ -77,6 +77,7 @@ export function createStore(definition) {
77
77
  const proxy = new Proxy({}, {
78
78
  get(_, prop) {
79
79
  if (signals[prop]) return signals[prop].peek();
80
+ if (computeds[prop]) return computeds[prop].peek();
80
81
  return undefined;
81
82
  },
82
83
  set(_, prop, val) {