what-core 0.5.3 → 0.5.5
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/README.md +119 -0
- package/package.json +6 -6
- package/src/animation.js +3 -1
- package/src/data.js +32 -5
- package/src/dom.js +34 -6
- package/src/form.js +49 -21
- package/src/index.js +2 -1
- package/src/reactive.js +21 -5
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# what-core
|
|
2
|
+
|
|
3
|
+
The reactive engine behind [What Framework](https://whatfw.com). Provides signals, fine-grained reactivity, components, hooks, and DOM rendering -- all without a virtual DOM diffing step.
|
|
4
|
+
|
|
5
|
+
Most users should install [`what-framework`](https://www.npmjs.com/package/what-framework) instead. `what-core` is the internal engine consumed by other What packages.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install what-core
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Reactive Primitives
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { signal, computed, effect, batch, untrack } from 'what-core';
|
|
17
|
+
|
|
18
|
+
const count = signal(0);
|
|
19
|
+
|
|
20
|
+
// Read
|
|
21
|
+
count(); // 0
|
|
22
|
+
|
|
23
|
+
// Write
|
|
24
|
+
count.set(5);
|
|
25
|
+
count.set(c => c + 1);
|
|
26
|
+
|
|
27
|
+
// Derived value
|
|
28
|
+
const doubled = computed(() => count() * 2);
|
|
29
|
+
|
|
30
|
+
// Side effects
|
|
31
|
+
effect(() => {
|
|
32
|
+
console.log('Count:', count());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Batch updates (effects run once at the end)
|
|
36
|
+
batch(() => {
|
|
37
|
+
a.set(1);
|
|
38
|
+
b.set(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Read without subscribing
|
|
42
|
+
untrack(() => someSignal());
|
|
43
|
+
count.peek();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Hooks
|
|
47
|
+
|
|
48
|
+
React-compatible hooks backed by signals internally.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import {
|
|
52
|
+
useState, useEffect, useMemo, useCallback,
|
|
53
|
+
useRef, useReducer, useContext, createContext,
|
|
54
|
+
onMount, onCleanup,
|
|
55
|
+
} from 'what-core';
|
|
56
|
+
|
|
57
|
+
const [count, setCount] = useState(0);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const id = setInterval(tick, 1000);
|
|
61
|
+
return () => clearInterval(id);
|
|
62
|
+
}, []);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Components
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { h, mount, Fragment, memo, lazy, Suspense, ErrorBoundary, Show, For } from 'what-core';
|
|
69
|
+
|
|
70
|
+
function Counter() {
|
|
71
|
+
const count = signal(0);
|
|
72
|
+
return h('button', { onclick: () => count.set(c => c + 1) }, () => count());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
mount(h(Counter), '#app');
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Additional Modules
|
|
79
|
+
|
|
80
|
+
| Export path | Contents |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `what-core` | Signals, hooks, components, store, forms, data fetching, animation, a11y, skeleton loaders |
|
|
83
|
+
| `what-core/render` | Fine-grained rendering primitives (`template`, `insert`, `spread`, `delegateEvents`) |
|
|
84
|
+
| `what-core/jsx-runtime` | JSX automatic runtime |
|
|
85
|
+
| `what-core/testing` | Test utilities |
|
|
86
|
+
|
|
87
|
+
## API Overview
|
|
88
|
+
|
|
89
|
+
**Reactivity** -- `signal`, `computed`, `effect`, `batch`, `untrack`, `flushSync`, `createRoot`
|
|
90
|
+
|
|
91
|
+
**Rendering** -- `h`, `Fragment`, `html`, `mount`, `template`, `insert`, `spread`, `delegateEvents`
|
|
92
|
+
|
|
93
|
+
**Hooks** -- `useState`, `useSignal`, `useComputed`, `useEffect`, `useMemo`, `useCallback`, `useRef`, `useContext`, `useReducer`, `createContext`, `onMount`, `onCleanup`, `createResource`
|
|
94
|
+
|
|
95
|
+
**Components** -- `memo`, `lazy`, `Suspense`, `ErrorBoundary`, `Show`, `For`, `Switch`, `Match`, `Island`, `Portal`
|
|
96
|
+
|
|
97
|
+
**Store** -- `createStore`, `derived`, `atom`
|
|
98
|
+
|
|
99
|
+
**Data Fetching** -- `useSWR`, `useQuery`, `useInfiniteQuery`, `invalidateQueries`, `prefetchQuery`
|
|
100
|
+
|
|
101
|
+
**Forms** -- `useForm`, `useField`, `rules`, `zodResolver`, `Input`, `Select`, `Checkbox`, `ErrorMessage`
|
|
102
|
+
|
|
103
|
+
**Animation** -- `spring`, `tween`, `easings`, `useGesture`, `useTransition`
|
|
104
|
+
|
|
105
|
+
**Accessibility** -- `useFocusTrap`, `FocusTrap`, `announce`, `SkipLink`, `useRovingTabIndex`, `VisuallyHidden`, `useId`
|
|
106
|
+
|
|
107
|
+
**Scheduler** -- `scheduleRead`, `scheduleWrite`, `measure`, `mutate`, `onResize`, `onIntersect`
|
|
108
|
+
|
|
109
|
+
**Head** -- `Head`, `clearHead`
|
|
110
|
+
|
|
111
|
+
## Links
|
|
112
|
+
|
|
113
|
+
- [Documentation](https://whatfw.com)
|
|
114
|
+
- [GitHub](https://github.com/CelsianJs/whatfw)
|
|
115
|
+
- [Benchmarks](https://benchmarks.whatfw.com)
|
|
116
|
+
|
|
117
|
+
## License
|
|
118
|
+
|
|
119
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
4
4
|
"description": "What Framework - The closest framework to vanilla JS",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
6
|
+
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
8
8
|
"types": "index.d.ts",
|
|
9
9
|
"exports": {
|
|
@@ -44,14 +44,14 @@
|
|
|
44
44
|
"lightweight",
|
|
45
45
|
"vdom"
|
|
46
46
|
],
|
|
47
|
-
"author": "",
|
|
47
|
+
"author": "ZVN DEV (https://zvndev.com)",
|
|
48
48
|
"license": "MIT",
|
|
49
49
|
"repository": {
|
|
50
50
|
"type": "git",
|
|
51
|
-
"url": "https://github.com/
|
|
51
|
+
"url": "https://github.com/CelsianJs/whatfw"
|
|
52
52
|
},
|
|
53
53
|
"bugs": {
|
|
54
|
-
"url": "https://github.com/
|
|
54
|
+
"url": "https://github.com/CelsianJs/whatfw/issues"
|
|
55
55
|
},
|
|
56
|
-
"homepage": "https://
|
|
56
|
+
"homepage": "https://whatfw.com"
|
|
57
57
|
}
|
package/src/animation.js
CHANGED
|
@@ -77,7 +77,9 @@ export function spring(initialValue, options = {}) {
|
|
|
77
77
|
|
|
78
78
|
function set(newTarget) {
|
|
79
79
|
target.set(newTarget);
|
|
80
|
-
|
|
80
|
+
// Use rafId check instead of isAnimating signal — signal may not have flushed
|
|
81
|
+
// after a synchronous stop() call, causing duplicate animation frames
|
|
82
|
+
if (rafId === null) {
|
|
81
83
|
isAnimating.set(true);
|
|
82
84
|
lastTime = null;
|
|
83
85
|
rafId = requestAnimationFrame(tick);
|
package/src/data.js
CHANGED
|
@@ -68,7 +68,7 @@ function subscribeToKey(key, revalidateFn) {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
const inFlightRequests = new Map();
|
|
71
|
+
const inFlightRequests = new Map(); // key -> { promise, timestamp, refCount }
|
|
72
72
|
const lastFetchTimestamps = new Map(); // key -> timestamp of last completed fetch
|
|
73
73
|
|
|
74
74
|
// Create an effect scoped to the current component's lifecycle.
|
|
@@ -200,6 +200,7 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
200
200
|
if (inFlightRequests.has(key)) {
|
|
201
201
|
const existing = inFlightRequests.get(key);
|
|
202
202
|
if (now - existing.timestamp < dedupingInterval) {
|
|
203
|
+
existing.refCount++;
|
|
203
204
|
return existing.promise;
|
|
204
205
|
}
|
|
205
206
|
}
|
|
@@ -210,15 +211,20 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
210
211
|
return cacheS.peek();
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// Abort previous request
|
|
214
|
-
if (abortController)
|
|
214
|
+
// Abort previous request only if no other subscribers are using it
|
|
215
|
+
if (abortController) {
|
|
216
|
+
const existing = inFlightRequests.get(key);
|
|
217
|
+
if (!existing || existing.refCount <= 1) {
|
|
218
|
+
abortController.abort();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
215
221
|
abortController = new AbortController();
|
|
216
222
|
const { signal: abortSignal } = abortController;
|
|
217
223
|
|
|
218
224
|
isValidating.set(true);
|
|
219
225
|
|
|
220
226
|
const promise = fetcher(key, { signal: abortSignal });
|
|
221
|
-
inFlightRequests.set(key, { promise, timestamp: now });
|
|
227
|
+
inFlightRequests.set(key, { promise, timestamp: now, refCount: 1 });
|
|
222
228
|
|
|
223
229
|
try {
|
|
224
230
|
const result = await promise;
|
|
@@ -238,7 +244,11 @@ export function useSWR(key, fetcher, options = {}) {
|
|
|
238
244
|
throw e;
|
|
239
245
|
} finally {
|
|
240
246
|
if (!abortSignal.aborted) isValidating.set(false);
|
|
241
|
-
inFlightRequests.
|
|
247
|
+
const flight = inFlightRequests.get(key);
|
|
248
|
+
if (flight) {
|
|
249
|
+
flight.refCount--;
|
|
250
|
+
if (flight.refCount <= 0) inFlightRequests.delete(key);
|
|
251
|
+
}
|
|
242
252
|
}
|
|
243
253
|
}
|
|
244
254
|
|
|
@@ -636,3 +646,20 @@ export function clearCache() {
|
|
|
636
646
|
lastFetchTimestamps.clear();
|
|
637
647
|
inFlightRequests.clear();
|
|
638
648
|
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Get a snapshot of all cache entries for devtools.
|
|
652
|
+
* @internal
|
|
653
|
+
*/
|
|
654
|
+
export function __getCacheSnapshot() {
|
|
655
|
+
const entries = [];
|
|
656
|
+
for (const [key, sig] of cacheSignals) {
|
|
657
|
+
entries.push({
|
|
658
|
+
key,
|
|
659
|
+
data: sig.peek(),
|
|
660
|
+
error: errorSignals.has(key) ? errorSignals.get(key).peek() : null,
|
|
661
|
+
isValidating: validatingSignals.has(key) ? validatingSignals.get(key).peek() : false,
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
return entries;
|
|
665
|
+
}
|
package/src/dom.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Components use <what-c> wrapper elements (display:contents) for clean reconciliation.
|
|
4
4
|
// No virtual DOM tree kept in memory — we diff against the live DOM.
|
|
5
5
|
|
|
6
|
-
import { effect, batch, untrack, signal } from './reactive.js';
|
|
6
|
+
import { effect, batch, untrack, signal, __DEV__, __devtools } from './reactive.js';
|
|
7
7
|
import { reportError, _injectGetCurrentComponent, shallowEqual } from './components.js';
|
|
8
8
|
import { _setComponentRef } from './helpers.js';
|
|
9
9
|
|
|
@@ -78,17 +78,18 @@ function disposeComponent(ctx) {
|
|
|
78
78
|
if (ctx.disposed) return;
|
|
79
79
|
ctx.disposed = true;
|
|
80
80
|
|
|
81
|
-
// Run useEffect cleanup functions
|
|
82
|
-
for (
|
|
81
|
+
// Run useEffect cleanup functions in reverse order (last effect first, matching React)
|
|
82
|
+
for (let i = ctx.hooks.length - 1; i >= 0; i--) {
|
|
83
|
+
const hook = ctx.hooks[i];
|
|
83
84
|
if (hook && typeof hook === 'object' && 'cleanup' in hook && hook.cleanup) {
|
|
84
85
|
try { hook.cleanup(); } catch (e) { console.error('[what] cleanup error:', e); }
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
// Run onCleanup callbacks
|
|
89
|
+
// Run onCleanup callbacks in reverse order (last registered first)
|
|
89
90
|
if (ctx._cleanupCallbacks) {
|
|
90
|
-
for (
|
|
91
|
-
try {
|
|
91
|
+
for (let i = ctx._cleanupCallbacks.length - 1; i >= 0; i--) {
|
|
92
|
+
try { ctx._cleanupCallbacks[i](); } catch (e) { console.error('[what] onCleanup error:', e); }
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -97,6 +98,7 @@ function disposeComponent(ctx) {
|
|
|
97
98
|
try { dispose(); } catch (e) { /* effect already disposed */ }
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
if (__DEV__ && __devtools?.onComponentUnmount) __devtools.onComponentUnmount(ctx);
|
|
100
102
|
mountedComponents.delete(ctx);
|
|
101
103
|
}
|
|
102
104
|
|
|
@@ -110,6 +112,12 @@ export function disposeTree(node) {
|
|
|
110
112
|
if (node._dispose) {
|
|
111
113
|
try { node._dispose(); } catch (e) { /* already disposed */ }
|
|
112
114
|
}
|
|
115
|
+
// Dispose reactive prop effects (value: () => ..., class: () => ..., etc.)
|
|
116
|
+
if (node._propEffects) {
|
|
117
|
+
for (const key in node._propEffects) {
|
|
118
|
+
try { node._propEffects[key](); } catch (e) { /* already disposed */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
113
121
|
if (node.childNodes) {
|
|
114
122
|
for (const child of node.childNodes) {
|
|
115
123
|
disposeTree(child);
|
|
@@ -304,6 +312,7 @@ function createComponent(vnode, parent, isSvg) {
|
|
|
304
312
|
|
|
305
313
|
// Track for disposal
|
|
306
314
|
mountedComponents.add(ctx);
|
|
315
|
+
if (__DEV__ && __devtools?.onComponentMount) __devtools.onComponentMount(ctx);
|
|
307
316
|
|
|
308
317
|
// Props signal for reactive updates from parent
|
|
309
318
|
// Match React's children semantics: 0→undefined, 1→single child, N→array
|
|
@@ -994,6 +1003,25 @@ function applyProps(el, newProps, oldProps, isSvg) {
|
|
|
994
1003
|
}
|
|
995
1004
|
|
|
996
1005
|
function setProp(el, key, value, isSvg) {
|
|
1006
|
+
// Reactive function props — wrap in effect() for fine-grained updates.
|
|
1007
|
+
// Applies to any non-event prop where the value is a function, e.g.:
|
|
1008
|
+
// h('input', { value: () => name(), class: () => active() ? 'on' : 'off' })
|
|
1009
|
+
// The function is called inside an effect, so signal reads create subscriptions.
|
|
1010
|
+
// When signals change, the prop is re-applied automatically.
|
|
1011
|
+
if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
|
|
1012
|
+
// Store dispose functions on the element for cleanup
|
|
1013
|
+
if (!el._propEffects) el._propEffects = {};
|
|
1014
|
+
// Dispose previous effect for this prop if re-applying
|
|
1015
|
+
if (el._propEffects[key]) {
|
|
1016
|
+
try { el._propEffects[key](); } catch (e) { /* already disposed */ }
|
|
1017
|
+
}
|
|
1018
|
+
el._propEffects[key] = effect(() => {
|
|
1019
|
+
const resolved = value();
|
|
1020
|
+
setProp(el, key, resolved, isSvg);
|
|
1021
|
+
});
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
997
1025
|
// Event handlers: onClick -> click, onFocusCapture -> focus (capture phase)
|
|
998
1026
|
// Wrap in untrack so signal reads in handlers don't create subscriptions
|
|
999
1027
|
if (key.startsWith('on') && key.length > 2) {
|
package/src/form.js
CHANGED
|
@@ -62,6 +62,7 @@ function createFormController(options = {}) {
|
|
|
62
62
|
const isDirty = signal(false);
|
|
63
63
|
const isSubmitting = signal(false);
|
|
64
64
|
const isSubmitted = signal(false);
|
|
65
|
+
const isValidating = signal(false);
|
|
65
66
|
const submitCount = signal(0);
|
|
66
67
|
|
|
67
68
|
// Helper: get all current values as a plain object.
|
|
@@ -128,34 +129,42 @@ function createFormController(options = {}) {
|
|
|
128
129
|
async function validate(fieldName) {
|
|
129
130
|
if (!resolver) return true;
|
|
130
131
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
132
|
+
isValidating.set(true);
|
|
133
|
+
try {
|
|
134
|
+
const result = await resolver(getAllValues(false));
|
|
135
|
+
const nextErrors = result?.errors || {};
|
|
136
|
+
|
|
137
|
+
if (fieldName) {
|
|
138
|
+
const nextError = nextErrors[fieldName] ?? null;
|
|
139
|
+
setFieldError(fieldName, nextError);
|
|
140
|
+
return !nextError;
|
|
141
|
+
} else {
|
|
142
|
+
replaceAllErrors(nextErrors);
|
|
143
|
+
return Object.keys(nextErrors).length === 0;
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
isValidating.set(false);
|
|
141
147
|
}
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
// Register a field — only subscribes to THIS field's signal
|
|
145
151
|
function register(name, options = {}) {
|
|
146
152
|
const fieldSig = getFieldSignal(name);
|
|
147
|
-
|
|
148
|
-
name,
|
|
149
|
-
// Use getter so value is always fresh, even if register result is cached
|
|
150
|
-
get value() { return fieldSig(); },
|
|
151
|
-
onInput: (e) => {
|
|
152
|
-
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
|
|
153
|
-
setValue(name, value);
|
|
153
|
+
const isCheckbox = options.type === 'checkbox' || options.type === 'radio';
|
|
154
154
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const handler = (e) => {
|
|
156
|
+
const value = (e.target.type === 'checkbox' || e.target.type === 'radio')
|
|
157
|
+
? e.target.checked
|
|
158
|
+
: e.target.value;
|
|
159
|
+
setValue(name, value);
|
|
160
|
+
|
|
161
|
+
if (mode === 'onChange' || (isSubmitted.peek() && reValidateMode === 'onChange')) {
|
|
162
|
+
validate(name);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const result = {
|
|
167
|
+
name,
|
|
159
168
|
onBlur: () => {
|
|
160
169
|
getTouchedSignal(name).set(true);
|
|
161
170
|
|
|
@@ -166,6 +175,24 @@ function createFormController(options = {}) {
|
|
|
166
175
|
onFocus: () => {},
|
|
167
176
|
ref: options.ref,
|
|
168
177
|
};
|
|
178
|
+
|
|
179
|
+
if (isCheckbox) {
|
|
180
|
+
// Checkbox/radio: use checked prop + onchange event
|
|
181
|
+
Object.defineProperty(result, 'checked', {
|
|
182
|
+
get() { return !!fieldSig(); },
|
|
183
|
+
enumerable: true,
|
|
184
|
+
});
|
|
185
|
+
result.onchange = handler;
|
|
186
|
+
} else {
|
|
187
|
+
// Text/select/textarea: use value prop + oninput event
|
|
188
|
+
Object.defineProperty(result, 'value', {
|
|
189
|
+
get() { return fieldSig(); },
|
|
190
|
+
enumerable: true,
|
|
191
|
+
});
|
|
192
|
+
result.oninput = handler;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result;
|
|
169
196
|
}
|
|
170
197
|
|
|
171
198
|
// Set single field value — only triggers re-render for components reading this field
|
|
@@ -273,6 +300,7 @@ function createFormController(options = {}) {
|
|
|
273
300
|
},
|
|
274
301
|
isDirty: () => isDirty(),
|
|
275
302
|
isValid,
|
|
303
|
+
isValidating: () => isValidating(),
|
|
276
304
|
isSubmitting: () => isSubmitting(),
|
|
277
305
|
isSubmitted: () => isSubmitted(),
|
|
278
306
|
submitCount: () => submitCount(),
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The closest framework to vanilla JS.
|
|
3
3
|
|
|
4
4
|
// Reactive primitives
|
|
5
|
-
export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot } from './reactive.js';
|
|
5
|
+
export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot, __setDevToolsHooks } from './reactive.js';
|
|
6
6
|
|
|
7
7
|
// Fine-grained rendering primitives
|
|
8
8
|
export { template, insert, mapArray, spread, setProp, delegateEvents, on, classList } from './render.js';
|
|
@@ -132,6 +132,7 @@ export {
|
|
|
132
132
|
setQueryData,
|
|
133
133
|
getQueryData,
|
|
134
134
|
clearCache,
|
|
135
|
+
__getCacheSnapshot,
|
|
135
136
|
} from './data.js';
|
|
136
137
|
|
|
137
138
|
// Form utilities
|
package/src/reactive.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Signals + Effects: fine-grained reactivity without virtual DOM overhead
|
|
3
3
|
|
|
4
4
|
// Dev-mode flag — build tools can dead-code-eliminate when false
|
|
5
|
-
export const __DEV__ = typeof process !== 'undefined'
|
|
5
|
+
export const __DEV__ = typeof process !== 'undefined'
|
|
6
|
+
? process.env?.NODE_ENV !== 'production'
|
|
7
|
+
: true;
|
|
6
8
|
|
|
7
9
|
// DevTools hooks — set by what-devtools when installed.
|
|
8
10
|
// These are no-ops in production (dead-code eliminated with __DEV__).
|
|
@@ -22,7 +24,7 @@ let pendingEffects = [];
|
|
|
22
24
|
// A reactive value. Reading inside an effect auto-tracks the dependency.
|
|
23
25
|
// Writing triggers only the effects that depend on this signal.
|
|
24
26
|
|
|
25
|
-
export function signal(initial) {
|
|
27
|
+
export function signal(initial, debugName) {
|
|
26
28
|
let value = initial;
|
|
27
29
|
const subs = new Set();
|
|
28
30
|
|
|
@@ -59,6 +61,10 @@ export function signal(initial) {
|
|
|
59
61
|
};
|
|
60
62
|
|
|
61
63
|
sig._signal = true;
|
|
64
|
+
if (__DEV__) {
|
|
65
|
+
sig._subs = subs;
|
|
66
|
+
if (debugName) sig._debugName = debugName;
|
|
67
|
+
}
|
|
62
68
|
|
|
63
69
|
// Notify devtools of signal creation
|
|
64
70
|
if (__DEV__ && __devtools) __devtools.onSignalCreate(sig);
|
|
@@ -175,9 +181,13 @@ function _runEffect(e) {
|
|
|
175
181
|
try {
|
|
176
182
|
const result = e.fn();
|
|
177
183
|
if (typeof result === 'function') e._cleanup = result;
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
|
|
186
|
+
if (__DEV__) console.warn('[what] Error in stable effect:', err);
|
|
178
187
|
} finally {
|
|
179
188
|
currentEffect = prev;
|
|
180
189
|
}
|
|
190
|
+
if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun(e);
|
|
181
191
|
return;
|
|
182
192
|
}
|
|
183
193
|
|
|
@@ -185,6 +195,7 @@ function _runEffect(e) {
|
|
|
185
195
|
// Run effect cleanup from previous run
|
|
186
196
|
if (e._cleanup) {
|
|
187
197
|
try { e._cleanup(); } catch (err) {
|
|
198
|
+
if (__devtools?.onError) __devtools.onError(err, { type: 'effect-cleanup', effect: e });
|
|
188
199
|
if (__DEV__) console.warn('[what] Error in effect cleanup:', err);
|
|
189
200
|
}
|
|
190
201
|
e._cleanup = null;
|
|
@@ -197,9 +208,13 @@ function _runEffect(e) {
|
|
|
197
208
|
if (typeof result === 'function') {
|
|
198
209
|
e._cleanup = result;
|
|
199
210
|
}
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
|
|
213
|
+
throw err;
|
|
200
214
|
} finally {
|
|
201
215
|
currentEffect = prev;
|
|
202
216
|
}
|
|
217
|
+
if (__DEV__ && __devtools?.onEffectRun) __devtools.onEffectRun(e);
|
|
203
218
|
}
|
|
204
219
|
|
|
205
220
|
function _disposeEffect(e) {
|
|
@@ -238,6 +253,7 @@ function notify(subs) {
|
|
|
238
253
|
e._cleanup = result;
|
|
239
254
|
}
|
|
240
255
|
} catch (err) {
|
|
256
|
+
if (__devtools?.onError) __devtools.onError(err, { type: 'effect', effect: e });
|
|
241
257
|
if (__DEV__) console.warn('[what] Error in stable effect:', err);
|
|
242
258
|
} finally {
|
|
243
259
|
currentEffect = prev;
|
|
@@ -263,7 +279,7 @@ function scheduleMicrotask() {
|
|
|
263
279
|
|
|
264
280
|
function flush() {
|
|
265
281
|
let iterations = 0;
|
|
266
|
-
while (pendingEffects.length > 0 && iterations <
|
|
282
|
+
while (pendingEffects.length > 0 && iterations < 25) {
|
|
267
283
|
const batch = pendingEffects;
|
|
268
284
|
pendingEffects = [];
|
|
269
285
|
for (let i = 0; i < batch.length; i++) {
|
|
@@ -273,12 +289,12 @@ function flush() {
|
|
|
273
289
|
}
|
|
274
290
|
iterations++;
|
|
275
291
|
}
|
|
276
|
-
if (iterations >=
|
|
292
|
+
if (iterations >= 25) {
|
|
277
293
|
if (__DEV__) {
|
|
278
294
|
const remaining = pendingEffects.slice(0, 3);
|
|
279
295
|
const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
|
|
280
296
|
console.warn(
|
|
281
|
-
`[what] Possible infinite effect loop detected (
|
|
297
|
+
`[what] Possible infinite effect loop detected (25 iterations). ` +
|
|
282
298
|
`Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
|
|
283
299
|
`Use untrack() to read signals without subscribing. ` +
|
|
284
300
|
`Looping effects: ${effectNames.join(', ')}`
|