what-core 0.1.1 → 0.3.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,367 @@
1
+ // What Framework - Testing Utilities
2
+ // Helpers for testing components, similar to @testing-library/react
3
+ // Works with Node.js test runner or any test framework
4
+
5
+ import { mount, h, signal, batch, effect } from './index.js';
6
+
7
+ // Minimal DOM implementation for Node.js
8
+ let container = null;
9
+
10
+ // --- Setup and Cleanup ---
11
+
12
+ export function setupDOM() {
13
+ if (typeof document !== 'undefined') {
14
+ // Browser environment
15
+ container = document.createElement('div');
16
+ container.id = 'test-root';
17
+ document.body.appendChild(container);
18
+ }
19
+ return container;
20
+ }
21
+
22
+ export function cleanup() {
23
+ if (container) {
24
+ container.innerHTML = '';
25
+ if (container.parentNode) {
26
+ container.parentNode.removeChild(container);
27
+ }
28
+ container = null;
29
+ }
30
+ }
31
+
32
+ // --- Render ---
33
+
34
+ export function render(vnode, options = {}) {
35
+ const { container: customContainer } = options;
36
+ const target = customContainer || setupDOM();
37
+
38
+ if (!target) {
39
+ throw new Error('No DOM container available. Are you running in Node.js without jsdom?');
40
+ }
41
+
42
+ const unmount = mount(vnode, target);
43
+
44
+ return {
45
+ container: target,
46
+ unmount,
47
+ // Query helpers
48
+ getByText: (text) => queryByText(target, text),
49
+ getByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
50
+ getByRole: (role) => target.querySelector(`[role="${role}"]`),
51
+ getAllByText: (text) => queryAllByText(target, text),
52
+ queryByText: (text) => queryByText(target, text),
53
+ queryByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
54
+ // Debug
55
+ debug: () => console.log(target.innerHTML),
56
+ // Async utilities
57
+ findByText: (text, timeout) => waitFor(() => queryByText(target, text), { timeout }),
58
+ findByTestId: (id, timeout) => waitFor(() => target.querySelector(`[data-testid="${id}"]`), { timeout }),
59
+ };
60
+ }
61
+
62
+ // --- Query Helpers ---
63
+
64
+ function queryByText(container, text) {
65
+ const regex = text instanceof RegExp ? text : null;
66
+ const walker = document.createTreeWalker(
67
+ container,
68
+ NodeFilter.SHOW_TEXT,
69
+ null,
70
+ false
71
+ );
72
+
73
+ while (walker.nextNode()) {
74
+ const node = walker.currentNode;
75
+ const matches = regex
76
+ ? regex.test(node.textContent)
77
+ : node.textContent.includes(text);
78
+ if (matches) {
79
+ return node.parentElement;
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function queryAllByText(container, text) {
86
+ const results = [];
87
+ const regex = text instanceof RegExp ? text : null;
88
+ const walker = document.createTreeWalker(
89
+ container,
90
+ NodeFilter.SHOW_TEXT,
91
+ null,
92
+ false
93
+ );
94
+
95
+ while (walker.nextNode()) {
96
+ const node = walker.currentNode;
97
+ const matches = regex
98
+ ? regex.test(node.textContent)
99
+ : node.textContent.includes(text);
100
+ if (matches) {
101
+ results.push(node.parentElement);
102
+ }
103
+ }
104
+ return results;
105
+ }
106
+
107
+ // --- Fire Events ---
108
+
109
+ export const fireEvent = {
110
+ click(element) {
111
+ const event = new MouseEvent('click', {
112
+ bubbles: true,
113
+ cancelable: true,
114
+ view: typeof window !== 'undefined' ? window : undefined,
115
+ });
116
+ element.dispatchEvent(event);
117
+ return event;
118
+ },
119
+
120
+ change(element, value) {
121
+ element.value = value;
122
+ const event = new Event('input', { bubbles: true });
123
+ element.dispatchEvent(event);
124
+ const changeEvent = new Event('change', { bubbles: true });
125
+ element.dispatchEvent(changeEvent);
126
+ return changeEvent;
127
+ },
128
+
129
+ input(element, value) {
130
+ element.value = value;
131
+ const event = new Event('input', { bubbles: true });
132
+ element.dispatchEvent(event);
133
+ return event;
134
+ },
135
+
136
+ submit(element) {
137
+ const event = new Event('submit', { bubbles: true, cancelable: true });
138
+ element.dispatchEvent(event);
139
+ return event;
140
+ },
141
+
142
+ focus(element) {
143
+ element.focus();
144
+ const event = new FocusEvent('focus', { bubbles: true });
145
+ element.dispatchEvent(event);
146
+ return event;
147
+ },
148
+
149
+ blur(element) {
150
+ element.blur();
151
+ const event = new FocusEvent('blur', { bubbles: true });
152
+ element.dispatchEvent(event);
153
+ return event;
154
+ },
155
+
156
+ keyDown(element, key, options = {}) {
157
+ const event = new KeyboardEvent('keydown', {
158
+ bubbles: true,
159
+ cancelable: true,
160
+ key,
161
+ ...options,
162
+ });
163
+ element.dispatchEvent(event);
164
+ return event;
165
+ },
166
+
167
+ keyUp(element, key, options = {}) {
168
+ const event = new KeyboardEvent('keyup', {
169
+ bubbles: true,
170
+ cancelable: true,
171
+ key,
172
+ ...options,
173
+ });
174
+ element.dispatchEvent(event);
175
+ return event;
176
+ },
177
+
178
+ mouseEnter(element) {
179
+ const event = new MouseEvent('mouseenter', { bubbles: true });
180
+ element.dispatchEvent(event);
181
+ return event;
182
+ },
183
+
184
+ mouseLeave(element) {
185
+ const event = new MouseEvent('mouseleave', { bubbles: true });
186
+ element.dispatchEvent(event);
187
+ return event;
188
+ },
189
+ };
190
+
191
+ // --- Wait Utilities ---
192
+
193
+ export async function waitFor(callback, options = {}) {
194
+ const { timeout = 1000, interval = 50 } = options;
195
+ const startTime = Date.now();
196
+
197
+ while (Date.now() - startTime < timeout) {
198
+ try {
199
+ const result = callback();
200
+ if (result) return result;
201
+ } catch (e) {
202
+ // Keep waiting
203
+ }
204
+ await new Promise(r => setTimeout(r, interval));
205
+ }
206
+
207
+ throw new Error(`waitFor timed out after ${timeout}ms`);
208
+ }
209
+
210
+ export async function waitForElementToBeRemoved(callback, options = {}) {
211
+ const { timeout = 1000, interval = 50 } = options;
212
+ const startTime = Date.now();
213
+
214
+ // First, element should exist
215
+ let element = callback();
216
+ if (!element) {
217
+ throw new Error('Element not found');
218
+ }
219
+
220
+ // Then wait for it to be removed
221
+ while (Date.now() - startTime < timeout) {
222
+ element = callback();
223
+ if (!element) return;
224
+ await new Promise(r => setTimeout(r, interval));
225
+ }
226
+
227
+ throw new Error(`Element still present after ${timeout}ms`);
228
+ }
229
+
230
+ // --- Act ---
231
+ // Ensure all effects and updates are flushed
232
+
233
+ export async function act(callback) {
234
+ const result = await callback();
235
+ // Wait for microtasks to flush
236
+ await new Promise(r => queueMicrotask(r));
237
+ // Wait for any scheduled effects
238
+ await new Promise(r => setTimeout(r, 0));
239
+ return result;
240
+ }
241
+
242
+ // --- Signal Testing Helpers ---
243
+
244
+ export function createTestSignal(initial) {
245
+ const s = signal(initial);
246
+ const history = [initial];
247
+
248
+ // Track all changes
249
+ effect(() => {
250
+ history.push(s());
251
+ });
252
+
253
+ return {
254
+ signal: s,
255
+ get value() { return s(); },
256
+ set value(v) { s.set(v); },
257
+ history,
258
+ reset() {
259
+ history.length = 0;
260
+ history.push(s());
261
+ },
262
+ };
263
+ }
264
+
265
+ // --- Mocking ---
266
+
267
+ export function mockComponent(name = 'MockComponent') {
268
+ const calls = [];
269
+
270
+ function Mock(props) {
271
+ calls.push({ props, timestamp: Date.now() });
272
+ return h('div', { 'data-testid': `mock-${name}` },
273
+ JSON.stringify(props, null, 2)
274
+ );
275
+ }
276
+
277
+ Mock.displayName = name;
278
+ Mock.calls = calls;
279
+ Mock.lastCall = () => calls[calls.length - 1];
280
+ Mock.reset = () => { calls.length = 0; };
281
+
282
+ return Mock;
283
+ }
284
+
285
+ // --- Assertions ---
286
+
287
+ export const expect = {
288
+ toBeInTheDocument(element) {
289
+ if (!element || !element.parentNode) {
290
+ throw new Error('Expected element to be in the document');
291
+ }
292
+ },
293
+
294
+ toHaveTextContent(element, text) {
295
+ if (!element) {
296
+ throw new Error('Element not found');
297
+ }
298
+ const content = element.textContent;
299
+ const matches = text instanceof RegExp ? text.test(content) : content.includes(text);
300
+ if (!matches) {
301
+ throw new Error(`Expected "${content}" to contain "${text}"`);
302
+ }
303
+ },
304
+
305
+ toHaveAttribute(element, attr, value) {
306
+ if (!element) {
307
+ throw new Error('Element not found');
308
+ }
309
+ const attrValue = element.getAttribute(attr);
310
+ if (value !== undefined && attrValue !== value) {
311
+ throw new Error(`Expected attribute "${attr}" to be "${value}", got "${attrValue}"`);
312
+ }
313
+ if (value === undefined && attrValue === null) {
314
+ throw new Error(`Expected element to have attribute "${attr}"`);
315
+ }
316
+ },
317
+
318
+ toHaveClass(element, className) {
319
+ if (!element) {
320
+ throw new Error('Element not found');
321
+ }
322
+ if (!element.classList.contains(className)) {
323
+ throw new Error(`Expected element to have class "${className}"`);
324
+ }
325
+ },
326
+
327
+ toBeVisible(element) {
328
+ if (!element) {
329
+ throw new Error('Element not found');
330
+ }
331
+ const style = window.getComputedStyle(element);
332
+ if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
333
+ throw new Error('Expected element to be visible');
334
+ }
335
+ },
336
+
337
+ toBeDisabled(element) {
338
+ if (!element) {
339
+ throw new Error('Element not found');
340
+ }
341
+ if (!element.disabled) {
342
+ throw new Error('Expected element to be disabled');
343
+ }
344
+ },
345
+
346
+ toHaveValue(element, value) {
347
+ if (!element) {
348
+ throw new Error('Element not found');
349
+ }
350
+ if (element.value !== value) {
351
+ throw new Error(`Expected value to be "${value}", got "${element.value}"`);
352
+ }
353
+ },
354
+ };
355
+
356
+ // --- Screen ---
357
+ // Global query object for convenience
358
+
359
+ export const screen = {
360
+ getByText: (text) => queryByText(document.body, text),
361
+ getByTestId: (id) => document.querySelector(`[data-testid="${id}"]`),
362
+ getByRole: (role) => document.querySelector(`[role="${role}"]`),
363
+ getAllByText: (text) => queryAllByText(document.body, text),
364
+ queryByText: (text) => queryByText(document.body, text),
365
+ queryByTestId: (id) => document.querySelector(`[data-testid="${id}"]`),
366
+ debug: () => console.log(document.body.innerHTML),
367
+ };
package/dist/what.js CHANGED
@@ -16,8 +16,8 @@ onMount,
16
16
  onCleanup,
17
17
  createResource,
18
18
  } from './hooks.js';
19
- export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match } from './components.js';
20
- export { createStore, atom } from './store.js';
19
+ export { memo, lazy, Suspense, ErrorBoundary, Show, For, Switch, Match, Island } from './components.js';
20
+ export { createStore, storeComputed, atom } from './store.js';
21
21
  export { Head, clearHead } from './head.js';
22
22
  export {
23
23
  show,
package/index.d.ts CHANGED
@@ -147,12 +147,27 @@ export type Store<T extends StoreDefinition> = {
147
147
  [K in keyof T]: T[K] extends (...args: any[]) => any ? T[K] : T[K];
148
148
  };
149
149
 
150
+ /** Mark a function as a computed property in a store definition */
151
+ export function storeComputed<T>(fn: (state: any) => T): (state: any) => T;
152
+
150
153
  /** Create a global reactive store */
151
154
  export function createStore<T extends StoreDefinition>(definition: T): () => Store<T>;
152
155
 
153
156
  /** Create a simple global atom */
154
157
  export function atom<T>(initial: T): Signal<T>;
155
158
 
159
+ // --- Island ---
160
+
161
+ export interface IslandProps {
162
+ component: Component<any>;
163
+ mode: 'load' | 'idle' | 'visible' | 'interaction' | 'media';
164
+ mediaQuery?: string;
165
+ [key: string]: any;
166
+ }
167
+
168
+ /** Island component for deferred hydration */
169
+ export function Island(props: IslandProps): VNode;
170
+
156
171
  // --- Utilities ---
157
172
 
158
173
  /** Conditional rendering helper */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-core",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "What Framework - The closest framework to vanilla JS",
5
5
  "type": "module",
6
6
  "main": "dist/what.js",
package/src/animation.js CHANGED
@@ -2,8 +2,17 @@
2
2
  // Springs, tweens, gestures, and transition helpers
3
3
 
4
4
  import { signal, effect, untrack, batch } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
  import { scheduleRead, scheduleWrite } from './scheduler.js';
6
7
 
8
+ // Create an effect scoped to the current component's lifecycle
9
+ function scopedEffect(fn) {
10
+ const ctx = getCurrentComponent?.();
11
+ const dispose = effect(fn);
12
+ if (ctx) ctx.effects.push(dispose);
13
+ return dispose;
14
+ }
15
+
7
16
  // --- Spring Animation ---
8
17
  // Physics-based animation with natural feel
9
18
 
@@ -388,14 +397,14 @@ export function useGesture(element, handlers = {}) {
388
397
  // Attach listeners
389
398
  if (typeof element === 'function') {
390
399
  // Ref function
391
- effect(() => {
400
+ scopedEffect(() => {
392
401
  const el = untrack(element);
393
402
  if (!el) return;
394
403
  return attachListeners(el);
395
404
  });
396
405
  } else if (element?.current !== undefined) {
397
406
  // Ref object
398
- effect(() => {
407
+ scopedEffect(() => {
399
408
  const el = element.current;
400
409
  if (!el) return;
401
410
  return attachListeners(el);
package/src/components.js CHANGED
@@ -202,3 +202,96 @@ export function Match({ when, children }) {
202
202
  // Match is just a marker component, Switch handles the logic
203
203
  return { tag: Match, props: { when }, children, _vnode: true };
204
204
  }
205
+
206
+ // --- Island ---
207
+ // Deferred hydration component for islands architecture.
208
+ // Usage: h(Island, { component: Counter, mode: 'idle' })
209
+ // The babel plugin compiles <Counter client:idle /> into this.
210
+
211
+ export function Island({ component: Component, mode, mediaQuery, ...props }) {
212
+ const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
213
+
214
+ // We need to return a vnode that the reconciler can handle.
215
+ // The actual hydration scheduling happens after mount via an effect.
216
+ const wrapper = signal(null);
217
+ const hydrated = signal(false);
218
+
219
+ function doHydrate() {
220
+ if (hydrated()) return;
221
+ hydrated.set(true);
222
+ // Render the actual component
223
+ wrapper.set(h(Component, props));
224
+ }
225
+
226
+ // Schedule hydration based on mode
227
+ function scheduleHydration(el) {
228
+ switch (mode) {
229
+ case 'load':
230
+ queueMicrotask(doHydrate);
231
+ break;
232
+
233
+ case 'idle':
234
+ if (typeof requestIdleCallback !== 'undefined') {
235
+ requestIdleCallback(doHydrate);
236
+ } else {
237
+ setTimeout(doHydrate, 200);
238
+ }
239
+ break;
240
+
241
+ case 'visible': {
242
+ const observer = new IntersectionObserver((entries) => {
243
+ if (entries[0].isIntersecting) {
244
+ observer.disconnect();
245
+ doHydrate();
246
+ }
247
+ });
248
+ observer.observe(el);
249
+ break;
250
+ }
251
+
252
+ case 'interaction': {
253
+ const hydrate = () => {
254
+ el.removeEventListener('click', hydrate);
255
+ el.removeEventListener('focus', hydrate);
256
+ el.removeEventListener('mouseenter', hydrate);
257
+ doHydrate();
258
+ };
259
+ el.addEventListener('click', hydrate, { once: true });
260
+ el.addEventListener('focus', hydrate, { once: true });
261
+ el.addEventListener('mouseenter', hydrate, { once: true });
262
+ break;
263
+ }
264
+
265
+ case 'media': {
266
+ if (!mediaQuery) { doHydrate(); break; }
267
+ const mq = window.matchMedia(mediaQuery);
268
+ if (mq.matches) {
269
+ queueMicrotask(doHydrate);
270
+ } else {
271
+ const checkMedia = () => {
272
+ if (mq.matches) {
273
+ mq.removeEventListener('change', checkMedia);
274
+ doHydrate();
275
+ }
276
+ };
277
+ mq.addEventListener('change', checkMedia);
278
+ }
279
+ break;
280
+ }
281
+
282
+ default:
283
+ // Unknown mode, hydrate immediately
284
+ queueMicrotask(doHydrate);
285
+ }
286
+ }
287
+
288
+ // Use ref callback to get the DOM element and schedule hydration
289
+ const refCallback = (el) => {
290
+ if (el) scheduleHydration(el);
291
+ };
292
+
293
+ // Return: show placeholder until hydrated, then show the real component
294
+ return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
295
+ hydrated() ? wrapper() : null
296
+ );
297
+ }
package/src/data.js CHANGED
@@ -2,11 +2,21 @@
2
2
  // SWR-like data fetching with caching, revalidation, and optimistic updates
3
3
 
4
4
  import { signal, effect, batch, computed } from './reactive.js';
5
+ import { getCurrentComponent } from './dom.js';
5
6
 
6
7
  // Global cache for requests
7
8
  const cache = new Map();
8
9
  const inFlightRequests = new Map();
9
10
 
11
+ // Create an effect scoped to the current component's lifecycle.
12
+ // When the component unmounts, the effect is automatically disposed.
13
+ function scopedEffect(fn) {
14
+ const ctx = getCurrentComponent?.();
15
+ const dispose = effect(fn);
16
+ if (ctx) ctx.effects.push(dispose);
17
+ return dispose;
18
+ }
19
+
10
20
  // --- useFetch Hook ---
11
21
  // Simple fetch with automatic JSON parsing and error handling
12
22
 
@@ -51,7 +61,7 @@ export function useFetch(url, options = {}) {
51
61
  }
52
62
 
53
63
  // Fetch on mount
54
- effect(() => {
64
+ scopedEffect(() => {
55
65
  fetchData();
56
66
  });
57
67
 
@@ -120,13 +130,13 @@ export function useSWR(key, fetcher, options = {}) {
120
130
  }
121
131
 
122
132
  // Initial fetch
123
- effect(() => {
133
+ scopedEffect(() => {
124
134
  revalidate().catch(() => {});
125
135
  });
126
136
 
127
137
  // Revalidate on focus
128
138
  if (revalidateOnFocus && typeof window !== 'undefined') {
129
- effect(() => {
139
+ scopedEffect(() => {
130
140
  const handler = () => {
131
141
  if (document.visibilityState === 'visible') {
132
142
  revalidate().catch(() => {});
@@ -139,7 +149,7 @@ export function useSWR(key, fetcher, options = {}) {
139
149
 
140
150
  // Revalidate on reconnect
141
151
  if (revalidateOnReconnect && typeof window !== 'undefined') {
142
- effect(() => {
152
+ scopedEffect(() => {
143
153
  const handler = () => revalidate().catch(() => {});
144
154
  window.addEventListener('online', handler);
145
155
  return () => window.removeEventListener('online', handler);
@@ -148,7 +158,7 @@ export function useSWR(key, fetcher, options = {}) {
148
158
 
149
159
  // Polling
150
160
  if (refreshInterval > 0) {
151
- effect(() => {
161
+ scopedEffect(() => {
152
162
  const interval = setInterval(() => {
153
163
  revalidate().catch(() => {});
154
164
  }, refreshInterval);
@@ -269,7 +279,7 @@ export function useQuery(options) {
269
279
  }
270
280
 
271
281
  // Initial fetch
272
- effect(() => {
282
+ scopedEffect(() => {
273
283
  if (enabled) {
274
284
  fetch().catch(() => {});
275
285
  }
@@ -277,7 +287,7 @@ export function useQuery(options) {
277
287
 
278
288
  // Refetch on focus
279
289
  if (refetchOnWindowFocus && typeof window !== 'undefined') {
280
- effect(() => {
290
+ scopedEffect(() => {
281
291
  const handler = () => {
282
292
  if (document.visibilityState === 'visible') {
283
293
  fetch().catch(() => {});
@@ -290,7 +300,7 @@ export function useQuery(options) {
290
300
 
291
301
  // Polling
292
302
  if (refetchInterval) {
293
- effect(() => {
303
+ scopedEffect(() => {
294
304
  const interval = setInterval(() => {
295
305
  fetch().catch(() => {});
296
306
  }, refetchInterval);
@@ -368,7 +378,7 @@ export function useInfiniteQuery(options) {
368
378
  }
369
379
 
370
380
  // Initial fetch
371
- effect(() => {
381
+ scopedEffect(() => {
372
382
  fetchPage(initialPageParam).catch(() => {});
373
383
  });
374
384