what-core 0.2.0 → 0.4.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/form.js CHANGED
@@ -15,23 +15,70 @@ export function useForm(options = {}) {
15
15
  resolver,
16
16
  } = options;
17
17
 
18
- // Form state
19
- const values = signal({ ...defaultValues });
20
- const errors = signal({});
21
- const touched = signal({});
18
+ // Per-field signals for granular reactivity (avoids full-form re-renders on each keystroke)
19
+ const fieldSignals = {};
20
+ const errorSignals = {};
21
+ const touchedSignals = {};
22
+
23
+ function getFieldSignal(name) {
24
+ if (!fieldSignals[name]) {
25
+ fieldSignals[name] = signal(defaultValues[name] ?? '');
26
+ }
27
+ return fieldSignals[name];
28
+ }
29
+
30
+ function getErrorSignal(name) {
31
+ if (!errorSignals[name]) {
32
+ errorSignals[name] = signal(null);
33
+ }
34
+ return errorSignals[name];
35
+ }
36
+
37
+ function getTouchedSignal(name) {
38
+ if (!touchedSignals[name]) {
39
+ touchedSignals[name] = signal(false);
40
+ }
41
+ return touchedSignals[name];
42
+ }
43
+
44
+ // Aggregate signals for bulk operations
22
45
  const isDirty = signal(false);
23
46
  const isSubmitting = signal(false);
24
47
  const isSubmitted = signal(false);
25
48
  const submitCount = signal(0);
26
49
 
50
+ // Helper: get all current values as a plain object
51
+ function getAllValues() {
52
+ const result = { ...defaultValues };
53
+ for (const [name, sig] of Object.entries(fieldSignals)) {
54
+ result[name] = sig.peek();
55
+ }
56
+ return result;
57
+ }
58
+
59
+ // Helper: get all current errors as a plain object
60
+ function getAllErrors() {
61
+ const result = {};
62
+ for (const [name, sig] of Object.entries(errorSignals)) {
63
+ const err = sig.peek();
64
+ if (err) result[name] = err;
65
+ }
66
+ return result;
67
+ }
68
+
27
69
  // Computed states
28
- const isValid = computed(() => Object.keys(errors()).length === 0);
70
+ const isValid = computed(() => {
71
+ for (const sig of Object.values(errorSignals)) {
72
+ if (sig()) return false;
73
+ }
74
+ return true;
75
+ });
76
+
29
77
  const dirtyFields = computed(() => {
30
78
  const dirty = {};
31
- const current = values();
32
- for (const key in current) {
33
- if (current[key] !== defaultValues[key]) {
34
- dirty[key] = true;
79
+ for (const [name, sig] of Object.entries(fieldSignals)) {
80
+ if (sig() !== (defaultValues[name] ?? '')) {
81
+ dirty[name] = true;
35
82
  }
36
83
  }
37
84
  return dirty;
@@ -41,31 +88,41 @@ export function useForm(options = {}) {
41
88
  async function validate(fieldName) {
42
89
  if (!resolver) return true;
43
90
 
44
- const result = await resolver(values());
91
+ const result = await resolver(getAllValues());
45
92
 
46
93
  if (fieldName) {
47
- // Validate single field
94
+ // Validate single field — only update that field's error signal
95
+ const errSig = getErrorSignal(fieldName);
48
96
  if (result.errors[fieldName]) {
49
- errors.set({ ...errors.peek(), [fieldName]: result.errors[fieldName] });
97
+ errSig.set(result.errors[fieldName]);
50
98
  return false;
51
99
  } else {
52
- const newErrors = { ...errors.peek() };
53
- delete newErrors[fieldName];
54
- errors.set(newErrors);
100
+ errSig.set(null);
55
101
  return true;
56
102
  }
57
103
  } else {
58
104
  // Validate all fields
59
- errors.set(result.errors || {});
105
+ batch(() => {
106
+ // Clear existing errors
107
+ for (const sig of Object.values(errorSignals)) {
108
+ sig.set(null);
109
+ }
110
+ // Set new errors
111
+ for (const [name, err] of Object.entries(result.errors || {})) {
112
+ getErrorSignal(name).set(err);
113
+ }
114
+ });
60
115
  return Object.keys(result.errors || {}).length === 0;
61
116
  }
62
117
  }
63
118
 
64
- // Register a field
119
+ // Register a field — only subscribes to THIS field's signal
65
120
  function register(name, options = {}) {
121
+ const fieldSig = getFieldSignal(name);
66
122
  return {
67
123
  name,
68
- value: values()[name] ?? '',
124
+ // Use getter so value is always fresh, even if register result is cached
125
+ get value() { return fieldSig(); },
69
126
  onInput: (e) => {
70
127
  const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
71
128
  setValue(name, value);
@@ -75,7 +132,7 @@ export function useForm(options = {}) {
75
132
  }
76
133
  },
77
134
  onBlur: () => {
78
- touched.set({ ...touched.peek(), [name]: true });
135
+ getTouchedSignal(name).set(true);
79
136
 
80
137
  if (mode === 'onBlur' || (isSubmitted.peek() && reValidateMode === 'onBlur')) {
81
138
  validate(name);
@@ -86,16 +143,12 @@ export function useForm(options = {}) {
86
143
  };
87
144
  }
88
145
 
89
- // Set single field value
146
+ // Set single field value — only triggers re-render for components reading this field
90
147
  function setValue(name, value, options = {}) {
91
148
  const { shouldValidate = false, shouldDirty = true } = options;
92
149
 
93
- batch(() => {
94
- values.set({ ...values.peek(), [name]: value });
95
- if (shouldDirty) {
96
- isDirty.set(true);
97
- }
98
- });
150
+ getFieldSignal(name).set(value);
151
+ if (shouldDirty) isDirty.set(true);
99
152
 
100
153
  if (shouldValidate) {
101
154
  validate(name);
@@ -104,32 +157,40 @@ export function useForm(options = {}) {
104
157
 
105
158
  // Get single field value
106
159
  function getValue(name) {
107
- return values()[name];
160
+ return getFieldSignal(name)();
108
161
  }
109
162
 
110
163
  // Set error for a field
111
164
  function setError(name, error) {
112
- errors.set({ ...errors.peek(), [name]: error });
165
+ getErrorSignal(name).set(error);
113
166
  }
114
167
 
115
168
  // Clear error for a field
116
169
  function clearError(name) {
117
- const newErrors = { ...errors.peek() };
118
- delete newErrors[name];
119
- errors.set(newErrors);
170
+ getErrorSignal(name).set(null);
120
171
  }
121
172
 
122
173
  // Clear all errors
123
174
  function clearErrors() {
124
- errors.set({});
175
+ batch(() => {
176
+ for (const sig of Object.values(errorSignals)) {
177
+ sig.set(null);
178
+ }
179
+ });
125
180
  }
126
181
 
127
182
  // Reset form
128
183
  function reset(newValues = defaultValues) {
129
184
  batch(() => {
130
- values.set({ ...newValues });
131
- errors.set({});
132
- touched.set({});
185
+ for (const [name, sig] of Object.entries(fieldSignals)) {
186
+ sig.set(newValues[name] ?? '');
187
+ }
188
+ for (const sig of Object.values(errorSignals)) {
189
+ sig.set(null);
190
+ }
191
+ for (const sig of Object.values(touchedSignals)) {
192
+ sig.set(false);
193
+ }
133
194
  isDirty.set(false);
134
195
  isSubmitted.set(false);
135
196
  });
@@ -147,21 +208,22 @@ export function useForm(options = {}) {
147
208
  const isFormValid = await validate();
148
209
 
149
210
  if (isFormValid) {
150
- await onValid(values.peek());
211
+ await onValid(getAllValues());
151
212
  } else if (onInvalid) {
152
- onInvalid(errors.peek());
213
+ onInvalid(getAllErrors());
153
214
  }
154
215
 
155
216
  isSubmitting.set(false);
156
217
  };
157
218
  }
158
219
 
159
- // Watch a field
220
+ // Watch a field — returns a computed that subscribes only to this field
160
221
  function watch(name) {
161
222
  if (name) {
162
- return computed(() => values()[name]);
223
+ return computed(() => getFieldSignal(name)());
163
224
  }
164
- return values;
225
+ // Watch all: return a computed that reads all field signals
226
+ return computed(() => getAllValues());
165
227
  }
166
228
 
167
229
  return {
@@ -175,11 +237,17 @@ export function useForm(options = {}) {
175
237
  reset,
176
238
  watch,
177
239
  validate,
178
- // Form state
240
+ // Form state — uses getters for errors/touched to enable per-field granularity
179
241
  formState: {
180
- values: () => values(),
181
- errors: () => errors(),
182
- touched: () => touched(),
242
+ get values() { return getAllValues(); },
243
+ get errors() { return getAllErrors(); },
244
+ get touched() {
245
+ const result = {};
246
+ for (const [name, sig] of Object.entries(touchedSignals)) {
247
+ if (sig()) result[name] = true;
248
+ }
249
+ return result;
250
+ },
183
251
  isDirty: () => isDirty(),
184
252
  isValid,
185
253
  isSubmitting: () => isSubmitting(),
package/dist/helpers.js CHANGED
@@ -1,17 +1,27 @@
1
1
  // What Framework - Helpers & Utilities
2
2
  // Commonly needed patterns, zero overhead.
3
3
 
4
- import { signal, effect, computed, batch } from './reactive.js';
4
+ import { signal, effect, computed, batch, __DEV__ } from './reactive.js';
5
5
 
6
- // --- show(condition, vnode) ---
6
+ // --- show(condition, vnode) --- [DEPRECATED: use <Show> component instead]
7
7
  // Conditional rendering. More readable than ternary.
8
+ let _showWarned = false;
8
9
  export function show(condition, vnode, fallback = null) {
10
+ if (!_showWarned) {
11
+ _showWarned = true;
12
+ console.warn('[what] show() is deprecated. Use the <Show> component or ternary expressions instead.');
13
+ }
9
14
  return condition ? vnode : fallback;
10
15
  }
11
16
 
12
- // --- each(list, fn) ---
17
+ // --- each(list, fn) --- [DEPRECATED: use <For> component or .map() instead]
13
18
  // Keyed list rendering. Optimized for reconciliation.
19
+ let _eachWarned = false;
14
20
  export function each(list, fn, keyFn) {
21
+ if (!_eachWarned) {
22
+ _eachWarned = true;
23
+ console.warn('[what] each() is deprecated. Use the <For> component or Array.map() instead.');
24
+ }
15
25
  if (!list || list.length === 0) return [];
16
26
  return list.map((item, index) => {
17
27
  const vnode = fn(item, index);
@@ -76,18 +86,31 @@ export function throttle(fn, ms) {
76
86
  };
77
87
  }
78
88
 
89
+ // Component context ref — injected by dom.js to avoid circular imports
90
+ let _getCurrentComponentRef = null;
91
+ export function _setComponentRef(fn) { _getCurrentComponentRef = fn; }
92
+
79
93
  // --- useMediaQuery ---
80
- // Reactive media query. Returns a signal.
94
+ // Reactive media query. Returns a signal. Cleans up listener on component unmount.
81
95
  export function useMediaQuery(query) {
82
96
  if (typeof window === 'undefined') return signal(false);
83
97
  const mq = window.matchMedia(query);
84
98
  const s = signal(mq.matches);
85
- mq.addEventListener('change', (e) => s.set(e.matches));
99
+ const handler = (e) => s.set(e.matches);
100
+ mq.addEventListener('change', handler);
101
+
102
+ // Register cleanup if inside a component context
103
+ const ctx = _getCurrentComponentRef?.();
104
+ if (ctx) {
105
+ ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
106
+ ctx._cleanupCallbacks.push(() => mq.removeEventListener('change', handler));
107
+ }
108
+
86
109
  return s;
87
110
  }
88
111
 
89
112
  // --- useLocalStorage ---
90
- // Signal synced with localStorage.
113
+ // Signal synced with localStorage. Cleans up listeners on component unmount.
91
114
  export function useLocalStorage(key, initial) {
92
115
  let stored;
93
116
  try {
@@ -100,18 +123,34 @@ export function useLocalStorage(key, initial) {
100
123
  const s = signal(stored);
101
124
 
102
125
  // Sync to localStorage on changes
103
- effect(() => {
126
+ const dispose = effect(() => {
104
127
  try {
105
128
  localStorage.setItem(key, JSON.stringify(s()));
106
- } catch { /* quota exceeded, etc */ }
129
+ } catch (e) {
130
+ if (__DEV__) console.warn('[what] localStorage write failed (quota exceeded?):', e);
131
+ }
107
132
  });
108
133
 
109
134
  // Listen for changes from other tabs
135
+ let storageHandler = null;
110
136
  if (typeof window !== 'undefined') {
111
- window.addEventListener('storage', (e) => {
137
+ storageHandler = (e) => {
112
138
  if (e.key === key && e.newValue !== null) {
113
- try { s.set(JSON.parse(e.newValue)); } catch {}
139
+ try { s.set(JSON.parse(e.newValue)); } catch (err) {
140
+ if (__DEV__) console.warn('[what] localStorage parse failed:', err);
141
+ }
114
142
  }
143
+ };
144
+ window.addEventListener('storage', storageHandler);
145
+ }
146
+
147
+ // Register cleanup if inside a component context
148
+ const ctx = _getCurrentComponentRef?.();
149
+ if (ctx) {
150
+ ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
151
+ ctx._cleanupCallbacks.push(() => {
152
+ dispose();
153
+ if (storageHandler) window.removeEventListener('storage', storageHandler);
115
154
  });
116
155
  }
117
156
 
@@ -131,6 +170,30 @@ export function Portal({ target, children }) {
131
170
  return { tag: '__portal', props: { container }, children: Array.isArray(children) ? children : [children], _vnode: true };
132
171
  }
133
172
 
173
+ // --- useClickOutside ---
174
+ // Detect clicks outside a ref'd element. Essential for dropdowns, modals, popovers.
175
+ export function useClickOutside(ref, handler) {
176
+ if (typeof document === 'undefined') return;
177
+
178
+ const listener = (e) => {
179
+ const el = ref.current || ref;
180
+ if (!el || el.contains(e.target)) return;
181
+ handler(e);
182
+ };
183
+
184
+ document.addEventListener('mousedown', listener);
185
+ document.addEventListener('touchstart', listener);
186
+
187
+ const ctx = _getCurrentComponentRef?.();
188
+ if (ctx) {
189
+ ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
190
+ ctx._cleanupCallbacks.push(() => {
191
+ document.removeEventListener('mousedown', listener);
192
+ document.removeEventListener('touchstart', listener);
193
+ });
194
+ }
195
+ }
196
+
134
197
  // --- Transition helper ---
135
198
  // Animate elements in/out. Returns props to spread on the element.
136
199
  export function transition(name, active) {
package/dist/hooks.js CHANGED
@@ -1,12 +1,17 @@
1
1
  // What Framework - Hooks
2
2
  // React-familiar hooks backed by signals. Zero overhead when deps don't change.
3
3
 
4
- import { signal, computed, effect, batch, untrack } from './reactive.js';
4
+ import { signal, computed, effect, batch, untrack, __DEV__ } from './reactive.js';
5
5
  import { getCurrentComponent } from './dom.js';
6
6
 
7
7
  function getCtx() {
8
8
  const ctx = getCurrentComponent();
9
- if (!ctx) throw new Error('Hooks must be called inside a component');
9
+ if (!ctx) {
10
+ throw new Error(
11
+ '[what] Hooks must be called inside a component function. ' +
12
+ 'If you need reactive state outside a component, use signal() directly.'
13
+ );
14
+ }
10
15
  return ctx;
11
16
  }
12
17
 
@@ -128,24 +133,56 @@ export function useRef(initial) {
128
133
  }
129
134
 
130
135
  // --- useContext ---
131
- // Read from a context created by createContext().
136
+ // Read from the nearest Provider in the component tree, or the default value.
137
+ // Uses _parentCtx chain (persistent tree) instead of componentStack (runtime stack)
138
+ // so context works correctly in re-renders, effects, and event handlers.
132
139
 
133
140
  export function useContext(context) {
134
- return context._value;
141
+ // Walk up the _parentCtx chain to find the nearest provider
142
+ let ctx = getCurrentComponent();
143
+ if (__DEV__ && !ctx) {
144
+ console.warn(
145
+ `[what] useContext(${context?.displayName || 'Context'}) called outside of component render. ` +
146
+ 'useContext must be called during component rendering, not inside effects or event handlers. ' +
147
+ 'Store the context value in a variable during render and use that variable in your callback.'
148
+ );
149
+ }
150
+ while (ctx) {
151
+ if (ctx._contextValues && ctx._contextValues.has(context)) {
152
+ const val = ctx._contextValues.get(context);
153
+ // If the stored value is a signal, read it to subscribe
154
+ return (val && val._signal) ? val() : val;
155
+ }
156
+ ctx = ctx._parentCtx;
157
+ }
158
+ return context._defaultValue;
135
159
  }
136
160
 
137
161
  // --- createContext ---
138
- // Simple context: set a default, override with Provider component.
162
+ // Tree-scoped context: Provider sets value for its subtree only.
163
+ // Multiple providers can coexist — each subtree sees its own value.
164
+ // Context values are wrapped in signals so consumers re-render when values change.
139
165
 
140
166
  export function createContext(defaultValue) {
141
- const ctx = {
142
- _value: defaultValue,
167
+ const context = {
168
+ _defaultValue: defaultValue,
143
169
  Provider: ({ value, children }) => {
144
- ctx._value = value;
170
+ const ctx = getCtx();
171
+ if (!ctx._contextValues) ctx._contextValues = new Map();
172
+ if (!ctx._contextSignals) ctx._contextSignals = new Map();
173
+
174
+ // Create or update the context signal
175
+ if (!ctx._contextSignals.has(context)) {
176
+ const s = signal(value);
177
+ ctx._contextSignals.set(context, s);
178
+ ctx._contextValues.set(context, s);
179
+ } else {
180
+ ctx._contextSignals.get(context).set(value);
181
+ }
145
182
  return children;
146
183
  },
147
184
  };
148
- return ctx;
185
+ return context;
149
186
  }
150
187
 
151
188
  // --- useReducer ---
@@ -173,7 +210,7 @@ export function useReducer(reducer, initialState, init) {
173
210
 
174
211
  export function onMount(fn) {
175
212
  const ctx = getCtx();
176
- if (!ctx._mounted) {
213
+ if (!ctx.mounted) {
177
214
  ctx._mountCallbacks = ctx._mountCallbacks || [];
178
215
  ctx._mountCallbacks.push(fn);
179
216
  }
@@ -197,26 +234,33 @@ export function createResource(fetcher, options = {}) {
197
234
  const loading = signal(!options.initialValue);
198
235
  const error = signal(null);
199
236
 
200
- let currentFetch = null;
237
+ let controller = null;
201
238
 
202
239
  const refetch = async (source) => {
240
+ // Abort previous request
241
+ if (controller) controller.abort();
242
+ controller = new AbortController();
243
+ const { signal: abortSignal } = controller;
244
+
203
245
  loading.set(true);
204
246
  error.set(null);
205
247
 
206
248
  try {
207
- const fetchPromise = fetcher(source);
208
- currentFetch = fetchPromise;
209
- const result = await fetchPromise;
210
-
211
- // Only update if this is still the current fetch
212
- if (currentFetch === fetchPromise) {
213
- data.set(result);
214
- loading.set(false);
249
+ const result = await fetcher(source, { signal: abortSignal });
250
+
251
+ // Only update if not aborted
252
+ if (!abortSignal.aborted) {
253
+ batch(() => {
254
+ data.set(result);
255
+ loading.set(false);
256
+ });
215
257
  }
216
258
  } catch (e) {
217
- if (currentFetch === fetcher) {
218
- error.set(e);
219
- loading.set(false);
259
+ if (!abortSignal.aborted) {
260
+ batch(() => {
261
+ error.set(e);
262
+ loading.set(false);
263
+ });
220
264
  }
221
265
  }
222
266
  };
@@ -225,6 +269,15 @@ export function createResource(fetcher, options = {}) {
225
269
  data.set(typeof value === 'function' ? value(data()) : value);
226
270
  };
227
271
 
272
+ // Register cleanup with component lifecycle: abort on unmount
273
+ const ctx = getCurrentComponent?.();
274
+ if (ctx) {
275
+ ctx._cleanupCallbacks = ctx._cleanupCallbacks || [];
276
+ ctx._cleanupCallbacks.push(() => {
277
+ if (controller) controller.abort();
278
+ });
279
+ }
280
+
228
281
  // Initial fetch if no initial value
229
282
  if (!options.initialValue) {
230
283
  refetch(options.source);
package/dist/index.js CHANGED
@@ -2,7 +2,10 @@
2
2
  // The closest framework to vanilla JS.
3
3
 
4
4
  // Reactive primitives
5
- export { signal, computed, effect, batch, untrack } from './reactive.js';
5
+ export { signal, computed, effect, memo as signalMemo, batch, untrack, flushSync, createRoot } from './reactive.js';
6
+
7
+ // Fine-grained rendering primitives
8
+ export { template, insert, mapArray, spread, delegateEvents, on, classList } from './render.js';
6
9
 
7
10
  // Virtual DOM
8
11
  export { h, Fragment, html } from './h.js';
@@ -31,7 +34,7 @@ export {
31
34
  export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
32
35
 
33
36
  // Store
34
- export { createStore, storeComputed, atom } from './store.js';
37
+ export { createStore, derived, storeComputed, atom } from './store.js';
35
38
 
36
39
  // Head management
37
40
  export { Head, clearHead } from './head.js';
@@ -46,6 +49,7 @@ export {
46
49
  throttle,
47
50
  useMediaQuery,
48
51
  useLocalStorage,
52
+ useClickOutside,
49
53
  Portal,
50
54
  transition,
51
55
  } from './helpers.js';