what-core 0.1.1 → 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/src/dom.js CHANGED
@@ -1,32 +1,89 @@
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
 
9
+ // SVG elements that need namespace
10
+ const SVG_ELEMENTS = new Set([
11
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
12
+ 'g', 'defs', 'use', 'symbol', 'clipPath', 'mask', 'pattern', 'image',
13
+ 'text', 'tspan', 'textPath', 'foreignObject', 'linearGradient', 'radialGradient', 'stop',
14
+ 'marker', 'animate', 'animateTransform', 'animateMotion', 'set', 'filter',
15
+ 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix',
16
+ 'feDiffuseLighting', 'feDisplacementMap', 'feFlood', 'feGaussianBlur', 'feImage',
17
+ 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'feSpecularLighting',
18
+ 'feTile', 'feTurbulence',
19
+ ]);
20
+ const SVG_NS = 'http://www.w3.org/2000/svg';
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
+
8
65
  // Mount a component tree into a DOM container
9
66
  export function mount(vnode, container) {
10
67
  if (typeof container === 'string') {
11
68
  container = document.querySelector(container);
12
69
  }
70
+ disposeTree(container); // Clean up any previous mount
13
71
  container.textContent = '';
14
72
  const node = createDOM(vnode, container);
15
73
  if (node) container.appendChild(node);
16
74
  return () => {
17
- // Unmount: clean up effects, remove DOM
75
+ disposeTree(container);
18
76
  container.textContent = '';
19
- // Disposal is handled by effect cleanup
20
77
  };
21
78
  }
22
79
 
23
80
  // --- Create DOM from VNode ---
24
81
 
25
- function createDOM(vnode, parent) {
26
- if (vnode == null || vnode === false || vnode === true) return null;
27
-
28
- // Real DOM node (from compiler runtime) — pass through
29
- if (typeof Node !== 'undefined' && vnode instanceof Node) return vnode;
82
+ function createDOM(vnode, parent, isSvg) {
83
+ // Null/false/true placeholder comment (preserves child indices for reconciliation)
84
+ if (vnode == null || vnode === false || vnode === true) {
85
+ return document.createComment('');
86
+ }
30
87
 
31
88
  // Text
32
89
  if (typeof vnode === 'string' || typeof vnode === 'number') {
@@ -37,7 +94,7 @@ function createDOM(vnode, parent) {
37
94
  if (Array.isArray(vnode)) {
38
95
  const frag = document.createDocumentFragment();
39
96
  for (const child of vnode) {
40
- const node = createDOM(child, parent);
97
+ const node = createDOM(child, parent, isSvg);
41
98
  if (node) frag.appendChild(node);
42
99
  }
43
100
  return frag;
@@ -45,14 +102,20 @@ function createDOM(vnode, parent) {
45
102
 
46
103
  // Component
47
104
  if (typeof vnode.tag === 'function') {
48
- return createComponent(vnode, parent);
105
+ return createComponent(vnode, parent, isSvg);
49
106
  }
50
107
 
51
- // HTML Element
52
- const el = document.createElement(vnode.tag);
53
- applyProps(el, vnode.props, {});
108
+ // Detect SVG context: either we're already in SVG, or this tag is an SVG element
109
+ const svgContext = isSvg || vnode.tag === 'svg' || SVG_ELEMENTS.has(vnode.tag);
110
+
111
+ // HTML or SVG Element
112
+ const el = svgContext
113
+ ? document.createElementNS(SVG_NS, vnode.tag)
114
+ : document.createElement(vnode.tag);
115
+
116
+ applyProps(el, vnode.props, {}, svgContext);
54
117
  for (const child of vnode.children) {
55
- const node = createDOM(child, el);
118
+ const node = createDOM(child, el, svgContext && vnode.tag !== 'foreignObject');
56
119
  if (node) el.appendChild(node);
57
120
  }
58
121
 
@@ -69,7 +132,11 @@ export function getCurrentComponent() {
69
132
  return componentStack[componentStack.length - 1];
70
133
  }
71
134
 
72
- function createComponent(vnode, parent) {
135
+ export function getComponentStack() {
136
+ return componentStack;
137
+ }
138
+
139
+ function createComponent(vnode, parent, isSvg) {
73
140
  const { tag: Component, props, children } = vnode;
74
141
 
75
142
  // Handle special boundary components
@@ -88,14 +155,29 @@ function createComponent(vnode, parent) {
88
155
  cleanups: [],
89
156
  mounted: false,
90
157
  disposed: false,
158
+ Component, // Store for identity check in patchNode
91
159
  };
92
160
 
93
- // Create a marker for this component's position
94
- 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);
95
175
 
96
- // Reactive render: re-renders when signals used inside change
97
- let currentNodes = [];
176
+ // Props signal for reactive updates from parent
177
+ const propsSignal = signal({ ...props, children });
178
+ ctx._propsSignal = propsSignal;
98
179
 
180
+ // Reactive render: re-renders when signals used inside change
99
181
  const dispose = effect(() => {
100
182
  if (ctx.disposed) return;
101
183
  ctx.hookIndex = 0;
@@ -104,12 +186,10 @@ function createComponent(vnode, parent) {
104
186
 
105
187
  let result;
106
188
  try {
107
- result = Component({ ...props, children });
189
+ result = Component(propsSignal());
108
190
  } catch (error) {
109
191
  componentStack.pop();
110
- // Try to report to nearest error boundary
111
192
  if (!reportError(error)) {
112
- // No boundary, re-throw
113
193
  console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
114
194
  throw error;
115
195
  }
@@ -123,28 +203,29 @@ function createComponent(vnode, parent) {
123
203
  if (!ctx.mounted) {
124
204
  // Initial mount
125
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
+
126
217
  for (const v of vnodes) {
127
- const node = createDOM(v, parent);
128
- if (node) {
129
- currentNodes.push(node);
130
- }
218
+ const node = createDOM(v, wrapper, isSvg);
219
+ if (node) wrapper.appendChild(node);
131
220
  }
132
221
  } else {
133
- // Update: reconcile
134
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
222
+ // Update: reconcile children inside wrapper
223
+ reconcileChildren(wrapper, vnodes);
135
224
  }
136
225
  });
137
226
 
138
227
  ctx.effects.push(dispose);
139
-
140
- // Return a fragment with marker + rendered nodes
141
- const frag = document.createDocumentFragment();
142
- frag.appendChild(marker);
143
- for (const node of currentNodes) {
144
- frag.appendChild(node);
145
- }
146
-
147
- return frag;
228
+ return wrapper;
148
229
  }
149
230
 
150
231
  // Error boundary component handler
@@ -152,51 +233,35 @@ function createErrorBoundary(vnode, parent) {
152
233
  const { errorState, handleError, fallback, reset } = vnode.props;
153
234
  const children = vnode.children;
154
235
 
155
- const marker = document.createComment('w:errorBoundary');
156
- let currentNodes = [];
157
-
158
- // Register this boundary
159
- const boundary = { handleError };
236
+ const wrapper = document.createElement('what-c');
237
+ wrapper.style.display = 'contents';
160
238
 
161
239
  const dispose = effect(() => {
162
240
  const error = errorState();
163
241
 
164
- // Push boundary to stack so children can find it
165
- errorBoundaryStack.push(boundary);
242
+ errorBoundaryStack.push({ handleError });
166
243
 
167
244
  let vnodes;
168
245
  if (error) {
169
- // Render fallback
170
- if (typeof fallback === 'function') {
171
- vnodes = [fallback({ error, reset })];
172
- } else {
173
- vnodes = [fallback];
174
- }
246
+ vnodes = typeof fallback === 'function' ? [fallback({ error, reset })] : [fallback];
175
247
  } else {
176
248
  vnodes = children;
177
249
  }
178
250
 
179
251
  errorBoundaryStack.pop();
180
-
181
252
  vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
182
253
 
183
- if (currentNodes.length === 0) {
184
- // Initial mount
254
+ if (wrapper.childNodes.length === 0) {
185
255
  for (const v of vnodes) {
186
- const node = createDOM(v, parent);
187
- if (node) currentNodes.push(node);
256
+ const node = createDOM(v, wrapper);
257
+ if (node) wrapper.appendChild(node);
188
258
  }
189
259
  } else {
190
- reconcile(marker.parentNode, currentNodes, vnodes, marker);
260
+ reconcileChildren(wrapper, vnodes);
191
261
  }
192
262
  });
193
263
 
194
- const frag = document.createDocumentFragment();
195
- frag.appendChild(marker);
196
- for (const node of currentNodes) {
197
- frag.appendChild(node);
198
- }
199
- return frag;
264
+ return wrapper;
200
265
  }
201
266
 
202
267
  // Suspense boundary component handler
@@ -204,30 +269,25 @@ function createSuspenseBoundary(vnode, parent) {
204
269
  const { boundary, fallback, loading } = vnode.props;
205
270
  const children = vnode.children;
206
271
 
207
- const marker = document.createComment('w:suspense');
208
- let currentNodes = [];
272
+ const wrapper = document.createElement('what-c');
273
+ wrapper.style.display = 'contents';
209
274
 
210
275
  const dispose = effect(() => {
211
276
  const isLoading = loading();
212
277
  const vnodes = isLoading ? [fallback] : children;
213
278
  const normalized = Array.isArray(vnodes) ? vnodes : [vnodes];
214
279
 
215
- if (currentNodes.length === 0) {
280
+ if (wrapper.childNodes.length === 0) {
216
281
  for (const v of normalized) {
217
- const node = createDOM(v, parent);
218
- if (node) currentNodes.push(node);
282
+ const node = createDOM(v, wrapper);
283
+ if (node) wrapper.appendChild(node);
219
284
  }
220
285
  } else {
221
- reconcile(marker.parentNode, currentNodes, normalized, marker);
286
+ reconcileChildren(wrapper, normalized);
222
287
  }
223
288
  });
224
289
 
225
- const frag = document.createDocumentFragment();
226
- frag.appendChild(marker);
227
- for (const node of currentNodes) {
228
- frag.appendChild(node);
229
- }
230
- return frag;
290
+ return wrapper;
231
291
  }
232
292
 
233
293
  // --- Reconciliation ---
@@ -237,7 +297,6 @@ function createSuspenseBoundary(vnode, parent) {
237
297
  function reconcile(parent, oldNodes, newVNodes, beforeMarker) {
238
298
  if (!parent) return;
239
299
 
240
- // Check if we have keyed children
241
300
  const hasKeys = newVNodes.some(v => v && typeof v === 'object' && v.key != null);
242
301
 
243
302
  if (hasKeys) {
@@ -259,6 +318,7 @@ function reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker) {
259
318
  if (i >= newVNodes.length) {
260
319
  // Remove extra old nodes
261
320
  if (oldNode && oldNode.parentNode) {
321
+ disposeTree(oldNode);
262
322
  oldNode.parentNode.removeChild(oldNode);
263
323
  }
264
324
  continue;
@@ -317,6 +377,7 @@ function reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker) {
317
377
  // Remove nodes that aren't reused
318
378
  for (let i = 0; i < oldNodes.length; i++) {
319
379
  if (!reused.has(i) && oldNodes[i]?.parentNode) {
380
+ disposeTree(oldNodes[i]);
320
381
  oldNodes[i].parentNode.removeChild(oldNodes[i]);
321
382
  }
322
383
  }
@@ -419,10 +480,17 @@ function getInsertionRef(nodes, marker) {
419
480
  }
420
481
 
421
482
  function patchNode(parent, domNode, vnode) {
422
- // Null/removed
483
+ // Null/removed → keep placeholder or replace with one
423
484
  if (vnode == null || vnode === false || vnode === true) {
424
- if (domNode && domNode.parentNode) domNode.parentNode.removeChild(domNode);
425
- 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;
426
494
  }
427
495
 
428
496
  // Text
@@ -433,6 +501,7 @@ function patchNode(parent, domNode, vnode) {
433
501
  return domNode;
434
502
  }
435
503
  const newNode = document.createTextNode(text);
504
+ disposeTree(domNode);
436
505
  parent.replaceChild(newNode, domNode);
437
506
  return newNode;
438
507
  }
@@ -445,13 +514,22 @@ function patchNode(parent, domNode, vnode) {
445
514
  const node = createDOM(v, parent);
446
515
  if (node) frag.appendChild(node);
447
516
  }
517
+ disposeTree(domNode);
448
518
  parent.replaceChild(frag, domNode);
449
519
  return frag;
450
520
  }
451
521
 
452
522
  // Component
453
523
  if (typeof vnode.tag === 'function') {
454
- // 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);
455
533
  const node = createComponent(vnode, parent);
456
534
  parent.replaceChild(node, domNode);
457
535
  return node;
@@ -468,6 +546,7 @@ function patchNode(parent, domNode, vnode) {
468
546
 
469
547
  // Different tag: replace entirely
470
548
  const newNode = createDOM(vnode, parent);
549
+ disposeTree(domNode);
471
550
  parent.replaceChild(newNode, domNode);
472
551
  return newNode;
473
552
  }
@@ -489,6 +568,7 @@ function reconcileChildren(parent, newChildVNodes) {
489
568
  if (i >= newChildVNodes.length) {
490
569
  // Remove extra
491
570
  if (oldChildren[i]?.parentNode) {
571
+ disposeTree(oldChildren[i]);
492
572
  parent.removeChild(oldChildren[i]);
493
573
  }
494
574
  continue;
@@ -509,7 +589,7 @@ function reconcileChildren(parent, newChildVNodes) {
509
589
  // --- Prop Diffing ---
510
590
  // Only touch DOM for props that actually changed.
511
591
 
512
- function applyProps(el, newProps, oldProps) {
592
+ function applyProps(el, newProps, oldProps, isSvg) {
513
593
  newProps = newProps || {};
514
594
  oldProps = oldProps || {};
515
595
 
@@ -525,7 +605,7 @@ function applyProps(el, newProps, oldProps) {
525
605
  for (const key in newProps) {
526
606
  if (key === 'key' || key === 'ref' || key === 'children') continue;
527
607
  if (newProps[key] !== oldProps[key]) {
528
- setProp(el, key, newProps[key]);
608
+ setProp(el, key, newProps[key], isSvg);
529
609
  }
530
610
  }
531
611
 
@@ -536,7 +616,7 @@ function applyProps(el, newProps, oldProps) {
536
616
  }
537
617
  }
538
618
 
539
- function setProp(el, key, value) {
619
+ function setProp(el, key, value, isSvg) {
540
620
  // Event handlers: onClick -> click
541
621
  // Wrap in untrack so signal reads in handlers don't create subscriptions
542
622
  if (key.startsWith('on') && key.length > 2) {
@@ -549,13 +629,19 @@ function setProp(el, key, value) {
549
629
  const wrappedHandler = (e) => untrack(() => value(e));
550
630
  wrappedHandler._original = value;
551
631
  el._events[event] = wrappedHandler;
552
- el.addEventListener(event, wrappedHandler);
632
+ // Check for _eventOpts (once/capture/passive from compiler)
633
+ const eventOpts = value._eventOpts;
634
+ el.addEventListener(event, wrappedHandler, eventOpts || undefined);
553
635
  return;
554
636
  }
555
637
 
556
- // className
638
+ // className / class
557
639
  if (key === 'className' || key === 'class') {
558
- el.className = value || '';
640
+ if (isSvg) {
641
+ el.setAttribute('class', value || '');
642
+ } else {
643
+ el.className = value || '';
644
+ }
559
645
  return;
560
646
  }
561
647
 
@@ -590,6 +676,16 @@ function setProp(el, key, value) {
590
676
  return;
591
677
  }
592
678
 
679
+ // SVG: always use setAttribute (SVG properties don't work as DOM properties)
680
+ if (isSvg) {
681
+ if (value === false || value == null) {
682
+ el.removeAttribute(key);
683
+ } else {
684
+ el.setAttribute(key, value === true ? '' : String(value));
685
+ }
686
+ return;
687
+ }
688
+
593
689
  // Default: set as property if it exists, otherwise attribute
594
690
  if (key in el) {
595
691
  el[key] = value;
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/index.js CHANGED
@@ -28,10 +28,10 @@ export {
28
28
  } from './hooks.js';
29
29
 
30
30
  // Component helpers
31
- export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match } from './components.js';
31
+ export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
32
32
 
33
33
  // Store
34
- export { createStore, atom } from './store.js';
34
+ export { createStore, storeComputed, atom } from './store.js';
35
35
 
36
36
  // Head management
37
37
  export { Head, clearHead } from './head.js';
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
@@ -4,15 +4,32 @@
4
4
 
5
5
  import { signal, computed, batch } from './reactive.js';
6
6
 
7
+ // --- storeComputed ---
8
+ // Marker wrapper to explicitly tag a function as a computed in createStore.
9
+ // Without this, createStore can't distinguish computed(state => ...) from action(item => ...).
10
+ //
11
+ // Usage:
12
+ // const useCounter = createStore({
13
+ // count: 0,
14
+ // doubled: storeComputed(state => state.count * 2),
15
+ // addItem(item) { /* this is an action */ },
16
+ // });
17
+
18
+ export function storeComputed(fn) {
19
+ fn._storeComputed = true;
20
+ return fn;
21
+ }
22
+
7
23
  // --- createStore ---
8
24
  // Creates a reactive store with actions. Each key becomes a signal.
9
25
  //
10
26
  // Usage:
11
27
  // const useCounter = createStore({
12
28
  // count: 0,
13
- // doubled: (state) => state.count * 2, // computed
14
- // increment() { this.count++; }, // action
29
+ // doubled: storeComputed(state => state.count * 2), // computed
30
+ // increment() { this.count++; }, // action
15
31
  // decrement() { this.count--; },
32
+ // addItem(item) { this.items.push(item); }, // action (not confused with computed)
16
33
  // });
17
34
  //
18
35
  // function Counter() {
@@ -27,12 +44,13 @@ export function createStore(definition) {
27
44
  const state = {};
28
45
 
29
46
  // Separate state, computeds, and actions
47
+ // Use explicit _storeComputed marker instead of function.length heuristic
30
48
  for (const [key, value] of Object.entries(definition)) {
31
- if (typeof value === 'function' && value.length > 0 && key !== 'constructor') {
32
- // Computed: function that takes state
49
+ if (typeof value === 'function' && value._storeComputed) {
50
+ // Computed: explicitly marked with storeComputed()
33
51
  computeds[key] = value;
34
52
  } else if (typeof value === 'function') {
35
- // Action: function with no args that uses `this`
53
+ // Action: any other function
36
54
  actions[key] = value;
37
55
  } else {
38
56
  // State: initial value
@@ -59,6 +77,7 @@ export function createStore(definition) {
59
77
  const proxy = new Proxy({}, {
60
78
  get(_, prop) {
61
79
  if (signals[prop]) return signals[prop].peek();
80
+ if (computeds[prop]) return computeds[prop].peek();
62
81
  return undefined;
63
82
  },
64
83
  set(_, prop, val) {