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 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",
3
+ "version": "0.5.5",
4
4
  "description": "What Framework - The closest framework to vanilla JS",
5
5
  "type": "module",
6
- "main": "dist/what.js",
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/zvndev/what-fw"
51
+ "url": "https://github.com/CelsianJs/whatfw"
52
52
  },
53
53
  "bugs": {
54
- "url": "https://github.com/zvndev/what-fw/issues"
54
+ "url": "https://github.com/CelsianJs/whatfw/issues"
55
55
  },
56
- "homepage": "https://whatframework.dev"
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
- if (!isAnimating.peek()) {
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) abortController.abort();
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.delete(key);
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 (const hook of ctx.hooks) {
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 (const fn of ctx._cleanupCallbacks) {
91
- try { fn(); } catch (e) { console.error('[what] onCleanup error:', e); }
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
- const result = await resolver(getAllValues(false));
132
- const nextErrors = result?.errors || {};
133
-
134
- if (fieldName) {
135
- const nextError = nextErrors[fieldName] ?? null;
136
- setFieldError(fieldName, nextError);
137
- return !nextError;
138
- } else {
139
- replaceAllErrors(nextErrors);
140
- return Object.keys(nextErrors).length === 0;
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
- return {
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
- if (mode === 'onChange' || (isSubmitted.peek() && reValidateMode === 'onChange')) {
156
- validate(name);
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' && process.env?.NODE_ENV !== 'production' || true;
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 < 100) {
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 >= 100) {
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 (100 iterations). ` +
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(', ')}`