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/dist/a11y.js +425 -0
- package/dist/animation.js +540 -0
- package/dist/components.js +272 -115
- package/dist/data.js +444 -0
- package/dist/dom.js +702 -427
- package/dist/form.js +441 -0
- package/dist/h.js +191 -138
- package/dist/head.js +59 -42
- package/dist/helpers.js +125 -83
- package/dist/hooks.js +226 -124
- package/dist/index.js +2 -2
- package/dist/reactive.js +165 -108
- package/dist/scheduler.js +241 -0
- package/dist/skeleton.js +363 -0
- package/dist/store.js +114 -55
- package/dist/testing.js +367 -0
- package/dist/what.js +2 -2
- package/index.d.ts +15 -0
- package/package.json +1 -1
- package/src/animation.js +11 -2
- package/src/components.js +93 -0
- package/src/data.js +19 -9
- package/src/dom.js +181 -85
- package/src/hooks.js +22 -10
- package/src/index.js +2 -2
- package/src/reactive.js +15 -1
- package/src/store.js +24 -5
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
|
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
|
-
//
|
|
94
|
-
|
|
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
|
-
//
|
|
97
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
156
|
-
|
|
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
|
-
|
|
165
|
-
errorBoundaryStack.push(boundary);
|
|
242
|
+
errorBoundaryStack.push({ handleError });
|
|
166
243
|
|
|
167
244
|
let vnodes;
|
|
168
245
|
if (error) {
|
|
169
|
-
|
|
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 (
|
|
184
|
-
// Initial mount
|
|
254
|
+
if (wrapper.childNodes.length === 0) {
|
|
185
255
|
for (const v of vnodes) {
|
|
186
|
-
const node = createDOM(v,
|
|
187
|
-
if (node)
|
|
256
|
+
const node = createDOM(v, wrapper);
|
|
257
|
+
if (node) wrapper.appendChild(node);
|
|
188
258
|
}
|
|
189
259
|
} else {
|
|
190
|
-
|
|
260
|
+
reconcileChildren(wrapper, vnodes);
|
|
191
261
|
}
|
|
192
262
|
});
|
|
193
263
|
|
|
194
|
-
|
|
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
|
|
208
|
-
|
|
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 (
|
|
280
|
+
if (wrapper.childNodes.length === 0) {
|
|
216
281
|
for (const v of normalized) {
|
|
217
|
-
const node = createDOM(v,
|
|
218
|
-
if (node)
|
|
282
|
+
const node = createDOM(v, wrapper);
|
|
283
|
+
if (node) wrapper.appendChild(node);
|
|
219
284
|
}
|
|
220
285
|
} else {
|
|
221
|
-
|
|
286
|
+
reconcileChildren(wrapper, normalized);
|
|
222
287
|
}
|
|
223
288
|
});
|
|
224
289
|
|
|
225
|
-
|
|
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.
|
|
425
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
131
|
+
// Read from the nearest Provider in the component tree, or the default value.
|
|
132
132
|
|
|
133
133
|
export function useContext(context) {
|
|
134
|
-
|
|
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
|
-
//
|
|
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
|
|
142
|
-
|
|
150
|
+
const context = {
|
|
151
|
+
_defaultValue: defaultValue,
|
|
143
152
|
Provider: ({ value, children }) => {
|
|
144
|
-
|
|
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
|
|
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.
|
|
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 ===
|
|
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
|
|
14
|
-
// increment() { this.count++; },
|
|
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.
|
|
32
|
-
// Computed:
|
|
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:
|
|
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) {
|