what-core 0.4.1 → 0.5.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,335 +1,229 @@
1
- // What Framework - Component Utilities
2
- // memo, lazy, Suspense, ErrorBoundary
3
-
4
1
  import { h } from './h.js';
5
2
  import { signal, effect, untrack, __DEV__ } from './reactive.js';
6
-
7
- // Legacy errorBoundaryStack removed — tree-based resolution via _parentCtx._errorBoundary
8
- // is now the only mechanism. See reportError() below.
9
-
10
- // --- memo ---
11
- // Skip re-render when parent re-renders with unchanged props.
12
- // Signal-safe: when an internal signal changes, the component always re-renders
13
- // (memo never blocks signal-triggered updates).
14
- //
15
- // How it works:
16
- // - In our architecture, Component(propsSignal()) is called inside an effect.
17
- // - When parent re-renders: propsSignal is set to a new object → props is a NEW reference
18
- // - When an internal signal changes: propsSignal unchanged → props is the SAME reference
19
- // - memo only skips when: props is a new reference AND structurally equal to previous
20
-
21
3
  export function memo(Component, areEqual) {
22
- const compare = areEqual || shallowEqual;
23
-
24
- function MemoWrapper(props) {
25
- const ctx = _getCurrentComponent?.();
26
- if (ctx && ctx._memoResult !== undefined) {
27
- if (props === ctx._memoPropsRef) {
28
- // Same reference → signal-triggered re-render → must re-run
29
- // to pick up new signal values (do NOT skip)
30
- } else if (compare(ctx._memoProps, props)) {
31
- // New reference but structurally equal → parent-triggered, safe to skip
32
- ctx._memoPropsRef = props;
33
- return ctx._memoResult;
34
- }
35
- }
36
- if (ctx) {
37
- ctx._memoPropsRef = props;
38
- ctx._memoProps = { ...props };
39
- }
40
- const result = Component(props);
41
- if (ctx) ctx._memoResult = result;
42
- return result;
43
- }
44
-
45
- MemoWrapper.displayName = `Memo(${Component.name || 'Anonymous'})`;
46
- return MemoWrapper;
47
- }
48
-
49
- // Injected by dom.js
4
+ const compare = areEqual || shallowEqual;
5
+ function MemoWrapper(props) {
6
+ const ctx = _getCurrentComponent?.();
7
+ if (ctx && ctx._memoResult !== undefined) {
8
+ if (props === ctx._memoPropsRef) {
9
+ } else if (compare(ctx._memoProps, props)) {
10
+ ctx._memoPropsRef = props;
11
+ return ctx._memoResult;
12
+ }
13
+ }
14
+ if (ctx) {
15
+ ctx._memoPropsRef = props;
16
+ ctx._memoProps = { ...props };
17
+ }
18
+ const result = Component(props);
19
+ if (ctx) ctx._memoResult = result;
20
+ return result;
21
+ }
22
+ MemoWrapper.displayName = `Memo(${Component.name || 'Anonymous'})`;
23
+ return MemoWrapper;
24
+ }
50
25
  let _getCurrentComponent = null;
51
26
  export function _injectGetCurrentComponent(fn) { _getCurrentComponent = fn; }
52
-
53
27
  function shallowEqual(a, b) {
54
- if (a === b) return true;
55
- const keysA = Object.keys(a);
56
- const keysB = Object.keys(b);
57
- if (keysA.length !== keysB.length) return false;
58
- for (const key of keysA) {
59
- if (!Object.is(a[key], b[key])) return false;
60
- }
61
- return true;
62
- }
63
-
64
- // --- lazy ---
65
- // Code-split a component. Returns a wrapper that loads on first render.
66
-
28
+ if (a === b) return true;
29
+ const keysA = Object.keys(a);
30
+ const keysB = Object.keys(b);
31
+ if (keysA.length !== keysB.length) return false;
32
+ for (const key of keysA) {
33
+ if (!Object.is(a[key], b[key])) return false;
34
+ }
35
+ return true;
36
+ }
67
37
  export function lazy(loader) {
68
- let Component = null;
69
- let loadPromise = null;
70
- let loadError = null;
71
- const listeners = new Set();
72
-
73
- function LazyWrapper(props) {
74
- if (loadError) throw loadError;
75
- if (Component) return h(Component, props);
76
-
77
- if (!loadPromise) {
78
- loadPromise = loader()
79
- .then(mod => {
80
- Component = mod.default || mod;
81
- // Notify all waiting instances
82
- listeners.forEach(fn => fn());
83
- listeners.clear();
84
- })
85
- .catch(err => { loadError = err; });
86
- }
87
-
88
- // Throw promise for Suspense to catch
89
- throw loadPromise;
90
- }
91
-
92
- LazyWrapper.displayName = 'Lazy';
93
- LazyWrapper._lazy = true;
94
- LazyWrapper._onLoad = (fn) => {
95
- if (Component) fn();
96
- else listeners.add(fn);
97
- };
98
- return LazyWrapper;
99
- }
100
-
101
- // --- Suspense ---
102
- // Show fallback while children are loading (lazy components).
103
- // Works with lazy() and async components.
104
-
38
+ let Component = null;
39
+ let loadPromise = null;
40
+ let loadError = null;
41
+ const listeners = new Set();
42
+ function LazyWrapper(props) {
43
+ if (loadError) throw loadError;
44
+ if (Component) return h(Component, props);
45
+ if (!loadPromise) {
46
+ loadPromise = loader()
47
+ .then(mod => {
48
+ Component = mod.default || mod;
49
+ listeners.forEach(fn => fn());
50
+ listeners.clear();
51
+ })
52
+ .catch(err => { loadError = err; });
53
+ }
54
+ throw loadPromise;
55
+ }
56
+ LazyWrapper.displayName = 'Lazy';
57
+ LazyWrapper._lazy = true;
58
+ LazyWrapper._onLoad = (fn) => {
59
+ if (Component) fn();
60
+ else listeners.add(fn);
61
+ };
62
+ return LazyWrapper;
63
+ }
105
64
  export function Suspense({ fallback, children }) {
106
- const loading = signal(false);
107
- const pendingPromises = new Set();
108
-
109
- // Suspense boundary marker
110
- const boundary = {
111
- _suspense: true,
112
- onSuspend(promise) {
113
- loading.set(true);
114
- pendingPromises.add(promise);
115
- promise.finally(() => {
116
- pendingPromises.delete(promise);
117
- if (pendingPromises.size === 0) {
118
- loading.set(false);
119
- }
120
- });
121
- },
122
- };
123
-
124
- return {
125
- tag: '__suspense',
126
- props: { boundary, fallback, loading },
127
- children: Array.isArray(children) ? children : [children],
128
- _vnode: true,
129
- };
130
- }
131
-
132
- // --- ErrorBoundary ---
133
- // Catch errors in children and show fallback.
134
- // Uses a signal to track error state so it works with reactive rendering.
135
-
65
+ const loading = signal(false);
66
+ const pendingPromises = new Set();
67
+ const boundary = {
68
+ _suspense: true,
69
+ onSuspend(promise) {
70
+ loading.set(true);
71
+ pendingPromises.add(promise);
72
+ promise.finally(() => {
73
+ pendingPromises.delete(promise);
74
+ if (pendingPromises.size === 0) {
75
+ loading.set(false);
76
+ }
77
+ });
78
+ },
79
+ };
80
+ return {
81
+ tag: '__suspense',
82
+ props: { boundary, fallback, loading },
83
+ children: Array.isArray(children) ? children : [children],
84
+ _vnode: true,
85
+ };
86
+ }
136
87
  export function ErrorBoundary({ fallback, children, onError }) {
137
- const errorState = signal(null);
138
-
139
- // Error handler that will be registered with the component tree
140
- const handleError = (error) => {
141
- errorState.set(error);
142
- if (onError) {
143
- try {
144
- onError(error);
145
- } catch (e) {
146
- console.error('Error in onError handler:', e);
147
- }
148
- }
149
- };
150
-
151
- // Reset function to recover from error
152
- const reset = () => errorState.set(null);
153
-
154
- return {
155
- tag: '__errorBoundary',
156
- props: { errorState, handleError, fallback, reset },
157
- children: Array.isArray(children) ? children : [children],
158
- _vnode: true,
159
- };
160
- }
161
-
162
- // Helper to report error to nearest boundary
163
- // Walks the component context tree (not a runtime stack) so async errors are caught
88
+ const errorState = signal(null);
89
+ const handleError = (error) => {
90
+ errorState.set(error);
91
+ if (onError) {
92
+ try {
93
+ onError(error);
94
+ } catch (e) {
95
+ console.error('Error in onError handler:', e);
96
+ }
97
+ }
98
+ };
99
+ const reset = () => errorState.set(null);
100
+ return {
101
+ tag: '__errorBoundary',
102
+ props: { errorState, handleError, fallback, reset },
103
+ children: Array.isArray(children) ? children : [children],
104
+ _vnode: true,
105
+ };
106
+ }
164
107
  export function reportError(error, startCtx) {
165
- // Walk up the _parentCtx chain to find the nearest _errorBoundary
166
- let ctx = startCtx || _getCurrentComponent?.();
167
- while (ctx) {
168
- if (ctx._errorBoundary) {
169
- ctx._errorBoundary(error);
170
- return true;
171
- }
172
- ctx = ctx._parentCtx;
173
- }
174
- return false;
175
- }
176
-
177
- // _getCurrentComponent is already declared above and injected via _injectGetCurrentComponent
178
-
179
- // --- Show ---
180
- // Conditional rendering component. Cleaner than ternaries.
181
-
108
+ let ctx = startCtx || _getCurrentComponent?.();
109
+ while (ctx) {
110
+ if (ctx._errorBoundary) {
111
+ ctx._errorBoundary(error);
112
+ return true;
113
+ }
114
+ ctx = ctx._parentCtx;
115
+ }
116
+ return false;
117
+ }
182
118
  export function Show({ when, fallback = null, children }) {
183
- // when can be a signal or a value
184
- const condition = typeof when === 'function' ? when() : when;
185
- return condition ? children : fallback;
186
- }
187
-
188
- // --- For ---
189
- // Efficient list rendering with keyed reconciliation.
190
-
119
+ const condition = typeof when === 'function' ? when() : when;
120
+ return condition ? children : fallback;
121
+ }
191
122
  export function For({ each, fallback = null, children }) {
192
- const list = typeof each === 'function' ? each() : each;
193
- if (!list || list.length === 0) return fallback;
194
-
195
- // children should be a function (item, index) => vnode
196
- const renderFn = Array.isArray(children) ? children[0] : children;
197
- if (typeof renderFn !== 'function') {
198
- console.warn('[what] For: children must be a render function, e.g. <For each={items}>{(item) => ...}</For>');
199
- return fallback;
200
- }
201
-
202
- return list.map((item, index) => {
203
- const vnode = renderFn(item, index);
204
- // Auto-detect keys for efficient keyed reconciliation
205
- if (vnode && typeof vnode === 'object' && vnode.key == null) {
206
- if (item != null && typeof item === 'object') {
207
- // Use item.id or item.key if available
208
- if (item.id != null) vnode.key = item.id;
209
- else if (item.key != null) vnode.key = item.key;
210
- } else if (typeof item === 'string' || typeof item === 'number') {
211
- // Primitive items can be their own key
212
- vnode.key = item;
213
- }
214
- }
215
- return vnode;
216
- });
217
- }
218
-
219
- // --- Switch / Match ---
220
- // Multi-condition rendering (like switch statement).
221
-
123
+ const list = typeof each === 'function' ? each() : each;
124
+ if (!list || list.length === 0) return fallback;
125
+ const renderFn = Array.isArray(children) ? children[0] : children;
126
+ if (typeof renderFn !== 'function') {
127
+ console.warn('[what] For: children must be a render function, e.g. <For each={items}>{(item) => ...}</For>');
128
+ return fallback;
129
+ }
130
+ return list.map((item, index) => {
131
+ const vnode = renderFn(item, index);
132
+ if (vnode && typeof vnode === 'object' && vnode.key == null) {
133
+ if (item != null && typeof item === 'object') {
134
+ if (item.id != null) vnode.key = item.id;
135
+ else if (item.key != null) vnode.key = item.key;
136
+ } else if (typeof item === 'string' || typeof item === 'number') {
137
+ vnode.key = item;
138
+ }
139
+ }
140
+ return vnode;
141
+ });
142
+ }
222
143
  export function Switch({ fallback = null, children }) {
223
- const kids = Array.isArray(children) ? children : [children];
224
-
225
- for (const child of kids) {
226
- if (child && child.tag === Match) {
227
- const condition = typeof child.props.when === 'function'
228
- ? child.props.when()
229
- : child.props.when;
230
- if (condition) {
231
- return child.children;
232
- }
233
- }
234
- }
235
-
236
- return fallback;
237
- }
238
-
144
+ const kids = Array.isArray(children) ? children : [children];
145
+ for (const child of kids) {
146
+ if (child && child.tag === Match) {
147
+ const condition = typeof child.props.when === 'function'
148
+ ? child.props.when()
149
+ : child.props.when;
150
+ if (condition) {
151
+ return child.children;
152
+ }
153
+ }
154
+ }
155
+ return fallback;
156
+ }
239
157
  export function Match({ when, children }) {
240
- // Match is just a marker component, Switch handles the logic
241
- return { tag: Match, props: { when }, children, _vnode: true };
242
- }
243
-
244
- // --- Island ---
245
- // Deferred hydration component for islands architecture.
246
- // Usage: h(Island, { component: Counter, mode: 'idle' })
247
- // The babel plugin compiles <Counter client:idle /> into this.
248
-
158
+ return { tag: Match, props: { when }, children, _vnode: true };
159
+ }
249
160
  export function Island({ component: Component, mode, mediaQuery, ...props }) {
250
- const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
251
-
252
- // We need to return a vnode that the reconciler can handle.
253
- // The actual hydration scheduling happens after mount via an effect.
254
- const wrapper = signal(null);
255
- const hydrated = signal(false);
256
-
257
- function doHydrate() {
258
- if (hydrated()) return;
259
- hydrated.set(true);
260
- // Render the actual component
261
- wrapper.set(h(Component, props));
262
- }
263
-
264
- // Schedule hydration based on mode
265
- function scheduleHydration(el) {
266
- switch (mode) {
267
- case 'load':
268
- queueMicrotask(doHydrate);
269
- break;
270
-
271
- case 'idle':
272
- if (typeof requestIdleCallback !== 'undefined') {
273
- requestIdleCallback(doHydrate);
274
- } else {
275
- setTimeout(doHydrate, 200);
276
- }
277
- break;
278
-
279
- case 'visible': {
280
- const observer = new IntersectionObserver((entries) => {
281
- if (entries[0].isIntersecting) {
282
- observer.disconnect();
283
- doHydrate();
284
- }
285
- });
286
- observer.observe(el);
287
- break;
288
- }
289
-
290
- case 'interaction': {
291
- const hydrate = () => {
292
- el.removeEventListener('click', hydrate);
293
- el.removeEventListener('focus', hydrate);
294
- el.removeEventListener('mouseenter', hydrate);
295
- doHydrate();
296
- };
297
- el.addEventListener('click', hydrate, { once: true });
298
- el.addEventListener('focus', hydrate, { once: true });
299
- el.addEventListener('mouseenter', hydrate, { once: true });
300
- break;
301
- }
302
-
303
- case 'media': {
304
- if (!mediaQuery) { doHydrate(); break; }
305
- const mq = window.matchMedia(mediaQuery);
306
- if (mq.matches) {
307
- queueMicrotask(doHydrate);
308
- } else {
309
- const checkMedia = () => {
310
- if (mq.matches) {
311
- mq.removeEventListener('change', checkMedia);
312
- doHydrate();
313
- }
314
- };
315
- mq.addEventListener('change', checkMedia);
316
- }
317
- break;
318
- }
319
-
320
- default:
321
- // Unknown mode, hydrate immediately
322
- queueMicrotask(doHydrate);
323
- }
324
- }
325
-
326
- // Use ref callback to get the DOM element and schedule hydration
327
- const refCallback = (el) => {
328
- if (el) scheduleHydration(el);
329
- };
330
-
331
- // Return: show placeholder until hydrated, then show the real component
332
- return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
333
- hydrated() ? wrapper() : null
334
- );
161
+ const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
162
+ const wrapper = signal(null);
163
+ const hydrated = signal(false);
164
+ function doHydrate() {
165
+ if (hydrated()) return;
166
+ hydrated.set(true);
167
+ wrapper.set(h(Component, props));
168
+ }
169
+ function scheduleHydration(el) {
170
+ switch (mode) {
171
+ case 'load':
172
+ queueMicrotask(doHydrate);
173
+ break;
174
+ case 'idle':
175
+ if (typeof requestIdleCallback !== 'undefined') {
176
+ requestIdleCallback(doHydrate);
177
+ } else {
178
+ setTimeout(doHydrate, 200);
179
+ }
180
+ break;
181
+ case 'visible': {
182
+ const observer = new IntersectionObserver((entries) => {
183
+ if (entries[0].isIntersecting) {
184
+ observer.disconnect();
185
+ doHydrate();
186
+ }
187
+ });
188
+ observer.observe(el);
189
+ break;
190
+ }
191
+ case 'interaction': {
192
+ const hydrate = () => {
193
+ el.removeEventListener('click', hydrate);
194
+ el.removeEventListener('focus', hydrate);
195
+ el.removeEventListener('mouseenter', hydrate);
196
+ doHydrate();
197
+ };
198
+ el.addEventListener('click', hydrate, { once: true });
199
+ el.addEventListener('focus', hydrate, { once: true });
200
+ el.addEventListener('mouseenter', hydrate, { once: true });
201
+ break;
202
+ }
203
+ case 'media': {
204
+ if (!mediaQuery) { doHydrate(); break; }
205
+ const mq = window.matchMedia(mediaQuery);
206
+ if (mq.matches) {
207
+ queueMicrotask(doHydrate);
208
+ } else {
209
+ const checkMedia = () => {
210
+ if (mq.matches) {
211
+ mq.removeEventListener('change', checkMedia);
212
+ doHydrate();
213
+ }
214
+ };
215
+ mq.addEventListener('change', checkMedia);
216
+ }
217
+ break;
218
+ }
219
+ default:
220
+ queueMicrotask(doHydrate);
221
+ }
335
222
  }
223
+ const refCallback = (el) => {
224
+ if (el) scheduleHydration(el);
225
+ };
226
+ return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
227
+ hydrated() ? wrapper() : null
228
+ );
229
+ }