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 +11 -2
- package/dist/data.js +19 -9
- package/dist/dom.js +134 -70
- package/dist/hooks.js +22 -10
- package/dist/reactive.js +15 -1
- package/dist/store.js +1 -0
- package/package.json +1 -1
- package/src/animation.js +11 -2
- package/src/data.js +19 -9
- package/src/dom.js +134 -70
- package/src/hooks.js +22 -10
- package/src/reactive.js +15 -1
- package/src/store.js +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
scopedEffect(() => {
|
|
124
134
|
revalidate().catch(() => {});
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
// Revalidate on focus
|
|
128
138
|
if (revalidateOnFocus && typeof window !== 'undefined') {
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
110
|
-
|
|
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
|
-
//
|
|
113
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
errorBoundaryStack.push(boundary);
|
|
242
|
+
errorBoundaryStack.push({ handleError });
|
|
182
243
|
|
|
183
244
|
let vnodes;
|
|
184
245
|
if (error) {
|
|
185
|
-
|
|
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 (
|
|
200
|
-
// Initial mount
|
|
254
|
+
if (wrapper.childNodes.length === 0) {
|
|
201
255
|
for (const v of vnodes) {
|
|
202
|
-
const node = createDOM(v,
|
|
203
|
-
if (node)
|
|
256
|
+
const node = createDOM(v, wrapper);
|
|
257
|
+
if (node) wrapper.appendChild(node);
|
|
204
258
|
}
|
|
205
259
|
} else {
|
|
206
|
-
|
|
260
|
+
reconcileChildren(wrapper, vnodes);
|
|
207
261
|
}
|
|
208
262
|
});
|
|
209
263
|
|
|
210
|
-
|
|
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
|
|
224
|
-
|
|
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 (
|
|
280
|
+
if (wrapper.childNodes.length === 0) {
|
|
232
281
|
for (const v of normalized) {
|
|
233
|
-
const node = createDOM(v,
|
|
234
|
-
if (node)
|
|
282
|
+
const node = createDOM(v, wrapper);
|
|
283
|
+
if (node) wrapper.appendChild(node);
|
|
235
284
|
}
|
|
236
285
|
} else {
|
|
237
|
-
|
|
286
|
+
reconcileChildren(wrapper, normalized);
|
|
238
287
|
}
|
|
239
288
|
});
|
|
240
289
|
|
|
241
|
-
|
|
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.
|
|
441
|
-
|
|
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
|
-
//
|
|
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
|
|
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/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
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
scopedEffect(() => {
|
|
124
134
|
revalidate().catch(() => {});
|
|
125
135
|
});
|
|
126
136
|
|
|
127
137
|
// Revalidate on focus
|
|
128
138
|
if (revalidateOnFocus && typeof window !== 'undefined') {
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
110
|
-
|
|
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
|
-
//
|
|
113
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
172
|
-
|
|
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
|
-
|
|
181
|
-
errorBoundaryStack.push(boundary);
|
|
242
|
+
errorBoundaryStack.push({ handleError });
|
|
182
243
|
|
|
183
244
|
let vnodes;
|
|
184
245
|
if (error) {
|
|
185
|
-
|
|
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 (
|
|
200
|
-
// Initial mount
|
|
254
|
+
if (wrapper.childNodes.length === 0) {
|
|
201
255
|
for (const v of vnodes) {
|
|
202
|
-
const node = createDOM(v,
|
|
203
|
-
if (node)
|
|
256
|
+
const node = createDOM(v, wrapper);
|
|
257
|
+
if (node) wrapper.appendChild(node);
|
|
204
258
|
}
|
|
205
259
|
} else {
|
|
206
|
-
|
|
260
|
+
reconcileChildren(wrapper, vnodes);
|
|
207
261
|
}
|
|
208
262
|
});
|
|
209
263
|
|
|
210
|
-
|
|
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
|
|
224
|
-
|
|
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 (
|
|
280
|
+
if (wrapper.childNodes.length === 0) {
|
|
232
281
|
for (const v of normalized) {
|
|
233
|
-
const node = createDOM(v,
|
|
234
|
-
if (node)
|
|
282
|
+
const node = createDOM(v, wrapper);
|
|
283
|
+
if (node) wrapper.appendChild(node);
|
|
235
284
|
}
|
|
236
285
|
} else {
|
|
237
|
-
|
|
286
|
+
reconcileChildren(wrapper, normalized);
|
|
238
287
|
}
|
|
239
288
|
});
|
|
240
289
|
|
|
241
|
-
|
|
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.
|
|
441
|
-
|
|
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
|
-
//
|
|
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
|
|
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/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