what-core 0.5.4 → 0.5.6

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 CHANGED
@@ -111,7 +111,7 @@ mount(h(Counter), '#app');
111
111
  ## Links
112
112
 
113
113
  - [Documentation](https://whatfw.com)
114
- - [GitHub](https://github.com/zvndev/what-fw)
114
+ - [GitHub](https://github.com/CelsianJs/whatfw)
115
115
  - [Benchmarks](https://benchmarks.whatfw.com)
116
116
 
117
117
  ## License
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "what-core",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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
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
 
package/src/dom.js CHANGED
@@ -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
 
@@ -111,6 +112,12 @@ export function disposeTree(node) {
111
112
  if (node._dispose) {
112
113
  try { node._dispose(); } catch (e) { /* already disposed */ }
113
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
+ }
114
121
  if (node.childNodes) {
115
122
  for (const child of node.childNodes) {
116
123
  disposeTree(child);
@@ -537,7 +544,7 @@ function reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker) {
537
544
  const node = createDOM(newVNode, parent);
538
545
  if (node) {
539
546
  const ref = getInsertionRef(oldNodes, beforeMarker);
540
- parent.insertBefore(node, ref);
547
+ safeInsertBefore(parent, node, ref);
541
548
  newNodes.push(node);
542
549
  }
543
550
  continue;
@@ -652,14 +659,14 @@ function reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker) {
652
659
 
653
660
  // Move if not in LIS
654
661
  if (!lisSet.has(i) && patched.parentNode) {
655
- parent.insertBefore(patched, lastInserted);
662
+ safeInsertBefore(parent, patched, lastInserted);
656
663
  }
657
664
  lastInserted = patched;
658
665
  } else {
659
666
  // Create new node
660
667
  const node = createDOM(vnode, parent);
661
668
  if (node) {
662
- parent.insertBefore(node, lastInserted);
669
+ safeInsertBefore(parent, node, lastInserted);
663
670
  lastInserted = node;
664
671
  }
665
672
  newNodes[i] = node;
@@ -718,6 +725,18 @@ function getInsertionRef(nodes, marker) {
718
725
  return marker ? marker.nextSibling : null;
719
726
  }
720
727
 
728
+ // Safe insertBefore: guards against stale reference nodes from nested reconciliation.
729
+ // When patchNode triggers a child component re-render (via propsSignal.set), the child's
730
+ // effect can run synchronously and mutate the DOM tree, leaving the parent's reference
731
+ // node detached. This helper falls back to appendChild when the ref is stale.
732
+ function safeInsertBefore(parent, node, ref) {
733
+ if (ref && ref.parentNode === parent) {
734
+ parent.insertBefore(node, ref);
735
+ } else {
736
+ parent.appendChild(node);
737
+ }
738
+ }
739
+
721
740
  // Helper: clean up array marker range (startMarker .. endMarker) and return a clean replacement node
722
741
  function cleanupArrayMarkers(parent, startMarker) {
723
742
  const endMarker = startMarker._arrayEnd;
@@ -996,6 +1015,25 @@ function applyProps(el, newProps, oldProps, isSvg) {
996
1015
  }
997
1016
 
998
1017
  function setProp(el, key, value, isSvg) {
1018
+ // Reactive function props — wrap in effect() for fine-grained updates.
1019
+ // Applies to any non-event prop where the value is a function, e.g.:
1020
+ // h('input', { value: () => name(), class: () => active() ? 'on' : 'off' })
1021
+ // The function is called inside an effect, so signal reads create subscriptions.
1022
+ // When signals change, the prop is re-applied automatically.
1023
+ if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
1024
+ // Store dispose functions on the element for cleanup
1025
+ if (!el._propEffects) el._propEffects = {};
1026
+ // Dispose previous effect for this prop if re-applying
1027
+ if (el._propEffects[key]) {
1028
+ try { el._propEffects[key](); } catch (e) { /* already disposed */ }
1029
+ }
1030
+ el._propEffects[key] = effect(() => {
1031
+ const resolved = value();
1032
+ setProp(el, key, resolved, isSvg);
1033
+ });
1034
+ return;
1035
+ }
1036
+
999
1037
  // Event handlers: onClick -> click, onFocusCapture -> focus (capture phase)
1000
1038
  // Wrap in untrack so signal reads in handlers don't create subscriptions
1001
1039
  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/reactive.js CHANGED
@@ -279,7 +279,7 @@ function scheduleMicrotask() {
279
279
 
280
280
  function flush() {
281
281
  let iterations = 0;
282
- while (pendingEffects.length > 0 && iterations < 100) {
282
+ while (pendingEffects.length > 0 && iterations < 25) {
283
283
  const batch = pendingEffects;
284
284
  pendingEffects = [];
285
285
  for (let i = 0; i < batch.length; i++) {
@@ -289,12 +289,12 @@ function flush() {
289
289
  }
290
290
  iterations++;
291
291
  }
292
- if (iterations >= 100) {
292
+ if (iterations >= 25) {
293
293
  if (__DEV__) {
294
294
  const remaining = pendingEffects.slice(0, 3);
295
295
  const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
296
296
  console.warn(
297
- `[what] Possible infinite effect loop detected (100 iterations). ` +
297
+ `[what] Possible infinite effect loop detected (25 iterations). ` +
298
298
  `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
299
299
  `Use untrack() to read signals without subscribing. ` +
300
300
  `Looping effects: ${effectNames.join(', ')}`
package/src/render.js CHANGED
@@ -136,7 +136,10 @@ function reconcileInsert(parent, value, current, marker) {
136
136
  for (let i = newNodes.length - 1; i >= 0; i--) {
137
137
  const node = newNodes[i];
138
138
  if (node.parentNode !== parent || node.nextSibling !== ref) {
139
- parent.insertBefore(node, ref);
139
+ // Guard against stale ref from nested reconciliation
140
+ if (ref && ref.parentNode !== parent) ref = null;
141
+ if (ref) parent.insertBefore(node, ref);
142
+ else parent.appendChild(node);
140
143
  }
141
144
  ref = node;
142
145
  }
@@ -360,6 +363,8 @@ function _reconcileMiddle(parent, endMarker, oldItems, newItems, mappedNodes, di
360
363
  const mi = i - start;
361
364
  if (oldIndices[mi] === -1 || !inLIS[mi]) {
362
365
  // New item or moved item — insert
366
+ // Guard against stale nextSibling from nested reconciliation
367
+ if (nextSibling && nextSibling.parentNode !== parent) nextSibling = endMarker;
363
368
  parent.insertBefore(newMapped[i], nextSibling);
364
369
  }
365
370
  nextSibling = newMapped[i];
@@ -649,6 +654,8 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
649
654
  for (let i = newEnd; i >= start; i--) {
650
655
  const mi = i - start;
651
656
  if (oldIndices[mi] === -1 || !inLIS[mi]) {
657
+ // Guard against stale nextSibling from nested reconciliation
658
+ if (nextSibling && nextSibling.parentNode !== parent) nextSibling = endMarker;
652
659
  parent.insertBefore(newMapped[i], nextSibling);
653
660
  }
654
661
  nextSibling = newMapped[i];