what-core 0.1.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.
@@ -0,0 +1,204 @@
1
+ // What Framework - Component Utilities
2
+ // memo, lazy, Suspense, ErrorBoundary
3
+
4
+ import { h } from './h.js';
5
+ import { signal, effect, untrack } from './reactive.js';
6
+
7
+ // Error boundary context - components can register their error handlers here
8
+ export const errorBoundaryStack = [];
9
+
10
+ // --- memo ---
11
+ // Skip re-render if props haven't changed (shallow compare by default).
12
+
13
+ export function memo(Component, areEqual) {
14
+ const compare = areEqual || shallowEqual;
15
+ let prevProps = null;
16
+ let prevResult = null;
17
+
18
+ function MemoWrapper(props) {
19
+ if (prevProps && compare(prevProps, props)) {
20
+ return prevResult;
21
+ }
22
+ prevProps = { ...props };
23
+ prevResult = Component(props);
24
+ return prevResult;
25
+ }
26
+
27
+ MemoWrapper.displayName = `Memo(${Component.name || 'Anonymous'})`;
28
+ return MemoWrapper;
29
+ }
30
+
31
+ function shallowEqual(a, b) {
32
+ if (a === b) return true;
33
+ const keysA = Object.keys(a);
34
+ const keysB = Object.keys(b);
35
+ if (keysA.length !== keysB.length) return false;
36
+ for (const key of keysA) {
37
+ if (!Object.is(a[key], b[key])) return false;
38
+ }
39
+ return true;
40
+ }
41
+
42
+ // --- lazy ---
43
+ // Code-split a component. Returns a wrapper that loads on first render.
44
+
45
+ export function lazy(loader) {
46
+ let Component = null;
47
+ let loadPromise = null;
48
+ let loadError = null;
49
+ const listeners = new Set();
50
+
51
+ function LazyWrapper(props) {
52
+ if (loadError) throw loadError;
53
+ if (Component) return h(Component, props);
54
+
55
+ if (!loadPromise) {
56
+ loadPromise = loader()
57
+ .then(mod => {
58
+ Component = mod.default || mod;
59
+ // Notify all waiting instances
60
+ listeners.forEach(fn => fn());
61
+ listeners.clear();
62
+ })
63
+ .catch(err => { loadError = err; });
64
+ }
65
+
66
+ // Throw promise for Suspense to catch
67
+ throw loadPromise;
68
+ }
69
+
70
+ LazyWrapper.displayName = 'Lazy';
71
+ LazyWrapper._lazy = true;
72
+ LazyWrapper._onLoad = (fn) => {
73
+ if (Component) fn();
74
+ else listeners.add(fn);
75
+ };
76
+ return LazyWrapper;
77
+ }
78
+
79
+ // --- Suspense ---
80
+ // Show fallback while children are loading (lazy components).
81
+ // Works with lazy() and async components.
82
+
83
+ export function Suspense({ fallback, children }) {
84
+ const loading = signal(false);
85
+ const pendingPromises = new Set();
86
+
87
+ // Suspense boundary marker
88
+ const boundary = {
89
+ _suspense: true,
90
+ onSuspend(promise) {
91
+ loading.set(true);
92
+ pendingPromises.add(promise);
93
+ promise.finally(() => {
94
+ pendingPromises.delete(promise);
95
+ if (pendingPromises.size === 0) {
96
+ loading.set(false);
97
+ }
98
+ });
99
+ },
100
+ };
101
+
102
+ return {
103
+ tag: '__suspense',
104
+ props: { boundary, fallback, loading },
105
+ children: Array.isArray(children) ? children : [children],
106
+ _vnode: true,
107
+ };
108
+ }
109
+
110
+ // --- ErrorBoundary ---
111
+ // Catch errors in children and show fallback.
112
+ // Uses a signal to track error state so it works with reactive rendering.
113
+
114
+ export function ErrorBoundary({ fallback, children, onError }) {
115
+ const errorState = signal(null);
116
+
117
+ // Error handler that will be registered with the component tree
118
+ const handleError = (error) => {
119
+ errorState.set(error);
120
+ if (onError) {
121
+ try {
122
+ onError(error);
123
+ } catch (e) {
124
+ console.error('Error in onError handler:', e);
125
+ }
126
+ }
127
+ };
128
+
129
+ // Reset function to recover from error
130
+ const reset = () => errorState.set(null);
131
+
132
+ return {
133
+ tag: '__errorBoundary',
134
+ props: { errorState, handleError, fallback, reset },
135
+ children: Array.isArray(children) ? children : [children],
136
+ _vnode: true,
137
+ };
138
+ }
139
+
140
+ // Helper to get current error boundary
141
+ export function getCurrentErrorBoundary() {
142
+ return errorBoundaryStack[errorBoundaryStack.length - 1] || null;
143
+ }
144
+
145
+ // Helper to report error to nearest boundary
146
+ export function reportError(error) {
147
+ const boundary = getCurrentErrorBoundary();
148
+ if (boundary) {
149
+ boundary.handleError(error);
150
+ return true;
151
+ }
152
+ return false;
153
+ }
154
+
155
+ // --- Show ---
156
+ // Conditional rendering component. Cleaner than ternaries.
157
+
158
+ export function Show({ when, fallback = null, children }) {
159
+ // when can be a signal or a value
160
+ const condition = typeof when === 'function' ? when() : when;
161
+ return condition ? children : fallback;
162
+ }
163
+
164
+ // --- For ---
165
+ // Efficient list rendering with keyed reconciliation.
166
+
167
+ export function For({ each, fallback = null, children }) {
168
+ const list = typeof each === 'function' ? each() : each;
169
+ if (!list || list.length === 0) return fallback;
170
+
171
+ // children should be a function (item, index) => vnode
172
+ const renderFn = Array.isArray(children) ? children[0] : children;
173
+ if (typeof renderFn !== 'function') {
174
+ console.warn('For: children must be a function');
175
+ return fallback;
176
+ }
177
+
178
+ return list.map((item, index) => renderFn(item, index));
179
+ }
180
+
181
+ // --- Switch / Match ---
182
+ // Multi-condition rendering (like switch statement).
183
+
184
+ export function Switch({ fallback = null, children }) {
185
+ const kids = Array.isArray(children) ? children : [children];
186
+
187
+ for (const child of kids) {
188
+ if (child && child.tag === Match) {
189
+ const condition = typeof child.props.when === 'function'
190
+ ? child.props.when()
191
+ : child.props.when;
192
+ if (condition) {
193
+ return child.children;
194
+ }
195
+ }
196
+ }
197
+
198
+ return fallback;
199
+ }
200
+
201
+ export function Match({ when, children }) {
202
+ // Match is just a marker component, Switch handles the logic
203
+ return { tag: Match, props: { when }, children, _vnode: true };
204
+ }
package/src/data.js ADDED
@@ -0,0 +1,434 @@
1
+ // What Framework - Data Fetching
2
+ // SWR-like data fetching with caching, revalidation, and optimistic updates
3
+
4
+ import { signal, effect, batch, computed } from './reactive.js';
5
+
6
+ // Global cache for requests
7
+ const cache = new Map();
8
+ const inFlightRequests = new Map();
9
+
10
+ // --- useFetch Hook ---
11
+ // Simple fetch with automatic JSON parsing and error handling
12
+
13
+ export function useFetch(url, options = {}) {
14
+ const {
15
+ method = 'GET',
16
+ body,
17
+ headers = {},
18
+ transform = (data) => data,
19
+ initialData = null,
20
+ } = options;
21
+
22
+ const data = signal(initialData);
23
+ const error = signal(null);
24
+ const isLoading = signal(true);
25
+
26
+ async function fetchData() {
27
+ isLoading.set(true);
28
+ error.set(null);
29
+
30
+ try {
31
+ const response = await fetch(url, {
32
+ method,
33
+ headers: {
34
+ 'Content-Type': 'application/json',
35
+ ...headers,
36
+ },
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
42
+ }
43
+
44
+ const json = await response.json();
45
+ data.set(transform(json));
46
+ } catch (e) {
47
+ error.set(e);
48
+ } finally {
49
+ isLoading.set(false);
50
+ }
51
+ }
52
+
53
+ // Fetch on mount
54
+ effect(() => {
55
+ fetchData();
56
+ });
57
+
58
+ return {
59
+ data: () => data(),
60
+ error: () => error(),
61
+ isLoading: () => isLoading(),
62
+ refetch: fetchData,
63
+ mutate: (newData) => data.set(newData),
64
+ };
65
+ }
66
+
67
+ // --- useSWR Hook ---
68
+ // Stale-while-revalidate pattern with caching
69
+
70
+ export function useSWR(key, fetcher, options = {}) {
71
+ const {
72
+ revalidateOnFocus = true,
73
+ revalidateOnReconnect = true,
74
+ refreshInterval = 0,
75
+ dedupingInterval = 2000,
76
+ fallbackData,
77
+ onSuccess,
78
+ onError,
79
+ suspense = false,
80
+ } = options;
81
+
82
+ // Reactive state
83
+ const data = signal(cache.get(key) || fallbackData || null);
84
+ const error = signal(null);
85
+ const isValidating = signal(false);
86
+ const isLoading = computed(() => !data() && isValidating());
87
+
88
+ async function revalidate() {
89
+ // Deduplication: if there's already a request in flight, wait for it
90
+ if (inFlightRequests.has(key)) {
91
+ const existingPromise = inFlightRequests.get(key);
92
+ const now = Date.now();
93
+ if (now - existingPromise.timestamp < dedupingInterval) {
94
+ return existingPromise.promise;
95
+ }
96
+ }
97
+
98
+ isValidating.set(true);
99
+
100
+ const promise = fetcher(key);
101
+ inFlightRequests.set(key, { promise, timestamp: Date.now() });
102
+
103
+ try {
104
+ const result = await promise;
105
+ batch(() => {
106
+ data.set(result);
107
+ error.set(null);
108
+ cache.set(key, result);
109
+ });
110
+ if (onSuccess) onSuccess(result, key);
111
+ return result;
112
+ } catch (e) {
113
+ error.set(e);
114
+ if (onError) onError(e, key);
115
+ throw e;
116
+ } finally {
117
+ isValidating.set(false);
118
+ inFlightRequests.delete(key);
119
+ }
120
+ }
121
+
122
+ // Initial fetch
123
+ effect(() => {
124
+ revalidate().catch(() => {});
125
+ });
126
+
127
+ // Revalidate on focus
128
+ if (revalidateOnFocus && typeof window !== 'undefined') {
129
+ effect(() => {
130
+ const handler = () => {
131
+ if (document.visibilityState === 'visible') {
132
+ revalidate().catch(() => {});
133
+ }
134
+ };
135
+ document.addEventListener('visibilitychange', handler);
136
+ return () => document.removeEventListener('visibilitychange', handler);
137
+ });
138
+ }
139
+
140
+ // Revalidate on reconnect
141
+ if (revalidateOnReconnect && typeof window !== 'undefined') {
142
+ effect(() => {
143
+ const handler = () => revalidate().catch(() => {});
144
+ window.addEventListener('online', handler);
145
+ return () => window.removeEventListener('online', handler);
146
+ });
147
+ }
148
+
149
+ // Polling
150
+ if (refreshInterval > 0) {
151
+ effect(() => {
152
+ const interval = setInterval(() => {
153
+ revalidate().catch(() => {});
154
+ }, refreshInterval);
155
+ return () => clearInterval(interval);
156
+ });
157
+ }
158
+
159
+ return {
160
+ data: () => data(),
161
+ error: () => error(),
162
+ isLoading,
163
+ isValidating: () => isValidating(),
164
+ mutate: (newData, shouldRevalidate = true) => {
165
+ data.set(typeof newData === 'function' ? newData(data()) : newData);
166
+ cache.set(key, data());
167
+ if (shouldRevalidate) {
168
+ revalidate().catch(() => {});
169
+ }
170
+ },
171
+ revalidate,
172
+ };
173
+ }
174
+
175
+ // --- useQuery Hook ---
176
+ // TanStack Query-like API
177
+
178
+ export function useQuery(options) {
179
+ const {
180
+ queryKey,
181
+ queryFn,
182
+ enabled = true,
183
+ staleTime = 0,
184
+ cacheTime = 5 * 60 * 1000,
185
+ refetchOnWindowFocus = true,
186
+ refetchInterval = false,
187
+ retry = 3,
188
+ retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30000),
189
+ onSuccess,
190
+ onError,
191
+ onSettled,
192
+ select,
193
+ placeholderData,
194
+ } = options;
195
+
196
+ const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
197
+
198
+ const rawData = signal(cache.get(key) || null);
199
+ const data = computed(() => {
200
+ const d = rawData();
201
+ return select && d !== null ? select(d) : d;
202
+ });
203
+ const error = signal(null);
204
+ const status = signal(cache.has(key) ? 'success' : 'loading');
205
+ const fetchStatus = signal('idle');
206
+
207
+ let lastFetchTime = 0;
208
+
209
+ async function fetch() {
210
+ if (!enabled) return;
211
+
212
+ // Check if data is still fresh
213
+ const now = Date.now();
214
+ if (cache.has(key) && now - lastFetchTime < staleTime) {
215
+ return cache.get(key);
216
+ }
217
+
218
+ fetchStatus.set('fetching');
219
+ if (!cache.has(key)) {
220
+ status.set('loading');
221
+ }
222
+
223
+ let attempts = 0;
224
+
225
+ async function attemptFetch() {
226
+ try {
227
+ const result = await queryFn({ queryKey: Array.isArray(queryKey) ? queryKey : [queryKey] });
228
+ batch(() => {
229
+ rawData.set(result);
230
+ error.set(null);
231
+ status.set('success');
232
+ fetchStatus.set('idle');
233
+ });
234
+ cache.set(key, result);
235
+ lastFetchTime = Date.now();
236
+
237
+ if (onSuccess) onSuccess(result);
238
+ if (onSettled) onSettled(result, null);
239
+
240
+ // Schedule cache cleanup
241
+ setTimeout(() => {
242
+ if (Date.now() - lastFetchTime >= cacheTime) {
243
+ cache.delete(key);
244
+ }
245
+ }, cacheTime);
246
+
247
+ return result;
248
+ } catch (e) {
249
+ attempts++;
250
+ if (attempts < retry) {
251
+ await new Promise(r => setTimeout(r, retryDelay(attempts)));
252
+ return attemptFetch();
253
+ }
254
+
255
+ batch(() => {
256
+ error.set(e);
257
+ status.set('error');
258
+ fetchStatus.set('idle');
259
+ });
260
+
261
+ if (onError) onError(e);
262
+ if (onSettled) onSettled(null, e);
263
+
264
+ throw e;
265
+ }
266
+ }
267
+
268
+ return attemptFetch();
269
+ }
270
+
271
+ // Initial fetch
272
+ effect(() => {
273
+ if (enabled) {
274
+ fetch().catch(() => {});
275
+ }
276
+ });
277
+
278
+ // Refetch on focus
279
+ if (refetchOnWindowFocus && typeof window !== 'undefined') {
280
+ effect(() => {
281
+ const handler = () => {
282
+ if (document.visibilityState === 'visible') {
283
+ fetch().catch(() => {});
284
+ }
285
+ };
286
+ document.addEventListener('visibilitychange', handler);
287
+ return () => document.removeEventListener('visibilitychange', handler);
288
+ });
289
+ }
290
+
291
+ // Polling
292
+ if (refetchInterval) {
293
+ effect(() => {
294
+ const interval = setInterval(() => {
295
+ fetch().catch(() => {});
296
+ }, refetchInterval);
297
+ return () => clearInterval(interval);
298
+ });
299
+ }
300
+
301
+ return {
302
+ data: () => data() ?? placeholderData,
303
+ error: () => error(),
304
+ status: () => status(),
305
+ fetchStatus: () => fetchStatus(),
306
+ isLoading: () => status() === 'loading',
307
+ isError: () => status() === 'error',
308
+ isSuccess: () => status() === 'success',
309
+ isFetching: () => fetchStatus() === 'fetching',
310
+ refetch: fetch,
311
+ };
312
+ }
313
+
314
+ // --- useInfiniteQuery Hook ---
315
+ // For paginated/infinite scroll data
316
+
317
+ export function useInfiniteQuery(options) {
318
+ const {
319
+ queryKey,
320
+ queryFn,
321
+ getNextPageParam,
322
+ getPreviousPageParam,
323
+ initialPageParam,
324
+ ...rest
325
+ } = options;
326
+
327
+ const pages = signal([]);
328
+ const pageParams = signal([initialPageParam]);
329
+ const hasNextPage = signal(true);
330
+ const hasPreviousPage = signal(false);
331
+ const isFetchingNextPage = signal(false);
332
+ const isFetchingPreviousPage = signal(false);
333
+
334
+ const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
335
+
336
+ async function fetchPage(pageParam, direction = 'next') {
337
+ const loading = direction === 'next' ? isFetchingNextPage : isFetchingPreviousPage;
338
+ loading.set(true);
339
+
340
+ try {
341
+ const result = await queryFn({
342
+ queryKey: Array.isArray(queryKey) ? queryKey : [queryKey],
343
+ pageParam,
344
+ });
345
+
346
+ batch(() => {
347
+ if (direction === 'next') {
348
+ pages.set([...pages.peek(), result]);
349
+ pageParams.set([...pageParams.peek(), pageParam]);
350
+ } else {
351
+ pages.set([result, ...pages.peek()]);
352
+ pageParams.set([pageParam, ...pageParams.peek()]);
353
+ }
354
+
355
+ const nextParam = getNextPageParam?.(result, pages.peek());
356
+ hasNextPage.set(nextParam !== undefined);
357
+
358
+ if (getPreviousPageParam) {
359
+ const prevParam = getPreviousPageParam(result, pages.peek());
360
+ hasPreviousPage.set(prevParam !== undefined);
361
+ }
362
+ });
363
+
364
+ return result;
365
+ } finally {
366
+ loading.set(false);
367
+ }
368
+ }
369
+
370
+ // Initial fetch
371
+ effect(() => {
372
+ fetchPage(initialPageParam).catch(() => {});
373
+ });
374
+
375
+ return {
376
+ data: () => ({ pages: pages(), pageParams: pageParams() }),
377
+ hasNextPage: () => hasNextPage(),
378
+ hasPreviousPage: () => hasPreviousPage(),
379
+ isFetchingNextPage: () => isFetchingNextPage(),
380
+ isFetchingPreviousPage: () => isFetchingPreviousPage(),
381
+ fetchNextPage: async () => {
382
+ const lastPage = pages.peek()[pages.peek().length - 1];
383
+ const nextParam = getNextPageParam?.(lastPage, pages.peek());
384
+ if (nextParam !== undefined) {
385
+ return fetchPage(nextParam, 'next');
386
+ }
387
+ },
388
+ fetchPreviousPage: async () => {
389
+ const firstPage = pages.peek()[0];
390
+ const prevParam = getPreviousPageParam?.(firstPage, pages.peek());
391
+ if (prevParam !== undefined) {
392
+ return fetchPage(prevParam, 'previous');
393
+ }
394
+ },
395
+ refetch: async () => {
396
+ pages.set([]);
397
+ pageParams.set([initialPageParam]);
398
+ return fetchPage(initialPageParam);
399
+ },
400
+ };
401
+ }
402
+
403
+ // --- Cache Management ---
404
+
405
+ export function invalidateQueries(keyOrPredicate) {
406
+ if (typeof keyOrPredicate === 'function') {
407
+ for (const [key] of cache) {
408
+ if (keyOrPredicate(key)) {
409
+ cache.delete(key);
410
+ }
411
+ }
412
+ } else {
413
+ cache.delete(keyOrPredicate);
414
+ }
415
+ }
416
+
417
+ export function prefetchQuery(key, fetcher) {
418
+ return fetcher(key).then(result => {
419
+ cache.set(key, result);
420
+ return result;
421
+ });
422
+ }
423
+
424
+ export function setQueryData(key, data) {
425
+ cache.set(key, typeof data === 'function' ? data(cache.get(key)) : data);
426
+ }
427
+
428
+ export function getQueryData(key) {
429
+ return cache.get(key);
430
+ }
431
+
432
+ export function clearCache() {
433
+ cache.clear();
434
+ }