what-core 0.1.0 → 0.2.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.
@@ -1,140 +1,297 @@
1
+ // What Framework - Component Utilities
2
+ // memo, lazy, Suspense, ErrorBoundary
3
+
1
4
  import { h } from './h.js';
2
5
  import { signal, effect, untrack } from './reactive.js';
6
+
7
+ // Error boundary context - components can register their error handlers here
3
8
  export const errorBoundaryStack = [];
9
+
10
+ // --- memo ---
11
+ // Skip re-render if props haven't changed (shallow compare by default).
12
+
4
13
  export function memo(Component, areEqual) {
5
- const compare = areEqual || shallowEqual;
6
- let prevProps = null;
7
- let prevResult = null;
8
- function MemoWrapper(props) {
9
- if (prevProps && compare(prevProps, props)) {
10
- return prevResult;
11
- }
12
- prevProps = { ...props };
13
- prevResult = Component(props);
14
- return prevResult;
15
- }
16
- MemoWrapper.displayName = `Memo(${Component.name || 'Anonymous'})`;
17
- return MemoWrapper;
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;
18
29
  }
30
+
19
31
  function shallowEqual(a, b) {
20
- if (a === b) return true;
21
- const keysA = Object.keys(a);
22
- const keysB = Object.keys(b);
23
- if (keysA.length !== keysB.length) return false;
24
- for (const key of keysA) {
25
- if (!Object.is(a[key], b[key])) return false;
26
- }
27
- return true;
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;
28
40
  }
41
+
42
+ // --- lazy ---
43
+ // Code-split a component. Returns a wrapper that loads on first render.
44
+
29
45
  export function lazy(loader) {
30
- let Component = null;
31
- let loadPromise = null;
32
- let loadError = null;
33
- const listeners = new Set();
34
- function LazyWrapper(props) {
35
- if (loadError) throw loadError;
36
- if (Component) return h(Component, props);
37
- if (!loadPromise) {
38
- loadPromise = loader()
39
- .then(mod => {
40
- Component = mod.default || mod;
41
- listeners.forEach(fn => fn());
42
- listeners.clear();
43
- })
44
- .catch(err => { loadError = err; });
45
- }
46
- throw loadPromise;
47
- }
48
- LazyWrapper.displayName = 'Lazy';
49
- LazyWrapper._lazy = true;
50
- LazyWrapper._onLoad = (fn) => {
51
- if (Component) fn();
52
- else listeners.add(fn);
53
- };
54
- return LazyWrapper;
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;
55
77
  }
78
+
79
+ // --- Suspense ---
80
+ // Show fallback while children are loading (lazy components).
81
+ // Works with lazy() and async components.
82
+
56
83
  export function Suspense({ fallback, children }) {
57
- const loading = signal(false);
58
- const pendingPromises = new Set();
59
- const boundary = {
60
- _suspense: true,
61
- onSuspend(promise) {
62
- loading.set(true);
63
- pendingPromises.add(promise);
64
- promise.finally(() => {
65
- pendingPromises.delete(promise);
66
- if (pendingPromises.size === 0) {
67
- loading.set(false);
68
- }
69
- });
70
- },
71
- };
72
- return {
73
- tag: '__suspense',
74
- props: { boundary, fallback, loading },
75
- children: Array.isArray(children) ? children : [children],
76
- _vnode: true,
77
- };
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
+ };
78
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
+
79
114
  export function ErrorBoundary({ fallback, children, onError }) {
80
- const errorState = signal(null);
81
- const handleError = (error) => {
82
- errorState.set(error);
83
- if (onError) {
84
- try {
85
- onError(error);
86
- } catch (e) {
87
- console.error('Error in onError handler:', e);
88
- }
89
- }
90
- };
91
- const reset = () => errorState.set(null);
92
- return {
93
- tag: '__errorBoundary',
94
- props: { errorState, handleError, fallback, reset },
95
- children: Array.isArray(children) ? children : [children],
96
- _vnode: true,
97
- };
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
+ };
98
138
  }
139
+
140
+ // Helper to get current error boundary
99
141
  export function getCurrentErrorBoundary() {
100
- return errorBoundaryStack[errorBoundaryStack.length - 1] || null;
142
+ return errorBoundaryStack[errorBoundaryStack.length - 1] || null;
101
143
  }
144
+
145
+ // Helper to report error to nearest boundary
102
146
  export function reportError(error) {
103
- const boundary = getCurrentErrorBoundary();
104
- if (boundary) {
105
- boundary.handleError(error);
106
- return true;
107
- }
108
- return false;
147
+ const boundary = getCurrentErrorBoundary();
148
+ if (boundary) {
149
+ boundary.handleError(error);
150
+ return true;
151
+ }
152
+ return false;
109
153
  }
154
+
155
+ // --- Show ---
156
+ // Conditional rendering component. Cleaner than ternaries.
157
+
110
158
  export function Show({ when, fallback = null, children }) {
111
- const condition = typeof when === 'function' ? when() : when;
112
- return condition ? children : fallback;
159
+ // when can be a signal or a value
160
+ const condition = typeof when === 'function' ? when() : when;
161
+ return condition ? children : fallback;
113
162
  }
163
+
164
+ // --- For ---
165
+ // Efficient list rendering with keyed reconciliation.
166
+
114
167
  export function For({ each, fallback = null, children }) {
115
- const list = typeof each === 'function' ? each() : each;
116
- if (!list || list.length === 0) return fallback;
117
- const renderFn = Array.isArray(children) ? children[0] : children;
118
- if (typeof renderFn !== 'function') {
119
- console.warn('For: children must be a function');
120
- return fallback;
121
- }
122
- return list.map((item, index) => renderFn(item, index));
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));
123
179
  }
180
+
181
+ // --- Switch / Match ---
182
+ // Multi-condition rendering (like switch statement).
183
+
124
184
  export function Switch({ fallback = null, children }) {
125
- const kids = Array.isArray(children) ? children : [children];
126
- for (const child of kids) {
127
- if (child && child.tag === Match) {
128
- const condition = typeof child.props.when === 'function'
129
- ? child.props.when()
130
- : child.props.when;
131
- if (condition) {
132
- return child.children;
133
- }
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;
134
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 };
135
204
  }
136
- return fallback;
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
+ );
137
297
  }
138
- export function Match({ when, children }) {
139
- return { tag: Match, props: { when }, children, _vnode: true };
140
- }