what-core 0.11.7 → 0.12.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/src/components.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { h } from './h.js';
5
5
  import { signal, effect, untrack, __DEV__ } from './reactive.js';
6
+ import { isServerRender } from './server-context.js';
6
7
 
7
8
  // Legacy errorBoundaryStack removed — tree-based resolution via _parentCtx._errorBoundary
8
9
  // is now the only mechanism. See reportError() below.
@@ -81,19 +82,31 @@ export function lazy(loader) {
81
82
  export function Suspense({ fallback, children }) {
82
83
  const loading = signal(false);
83
84
  const pendingPromises = new Set();
85
+ let failed = false;
84
86
 
85
87
  // Suspense boundary marker
86
88
  const boundary = {
87
89
  _suspense: true,
88
90
  onSuspend(promise) {
91
+ if (failed) return;
89
92
  loading.set(true);
90
93
  pendingPromises.add(promise);
91
- promise.finally(() => {
92
- pendingPromises.delete(promise);
93
- if (pendingPromises.size === 0) {
94
- loading.set(false);
95
- }
96
- });
94
+ // Rejection is handled separately from fulfilment. Clearing the fallback
95
+ // on a rejection re-renders the child, which suspends on the same
96
+ // rejected thenable again, forever. Latch the failure and stay put.
97
+ promise.then(
98
+ () => {
99
+ pendingPromises.delete(promise);
100
+ if (pendingPromises.size === 0) {
101
+ loading.set(false);
102
+ }
103
+ },
104
+ (err) => {
105
+ failed = true;
106
+ pendingPromises.delete(promise);
107
+ console.error('[what] Suspense: a suspended child rejected:', err);
108
+ },
109
+ );
97
110
  },
98
111
  };
99
112
 
@@ -105,6 +118,10 @@ export function Suspense({ fallback, children }) {
105
118
  };
106
119
  }
107
120
 
121
+ // The boundary context only exists once createSuspenseBoundary runs, so
122
+ // compiled children must not be built during this call. See createComponent.
123
+ Suspense._deferChildren = true;
124
+
108
125
  // --- ErrorBoundary ---
109
126
  // Catch errors in children and show fallback.
110
127
  // Uses a signal to track error state so it works with reactive rendering.
@@ -135,6 +152,10 @@ export function ErrorBoundary({ fallback, children, onError }) {
135
152
  };
136
153
  }
137
154
 
155
+ // The boundary context only exists once createErrorBoundary runs, so compiled
156
+ // children must not be built during this call. See createComponent.
157
+ ErrorBoundary._deferChildren = true;
158
+
138
159
  // Helper to report error to nearest boundary
139
160
  // Walks the component context tree (not a runtime stack) so async errors are caught
140
161
  export function reportError(error, startCtx) {
@@ -216,124 +237,196 @@ export function For({ each, fallback = null, children }) {
216
237
  // Multi-condition rendering (like switch statement).
217
238
 
218
239
  export function Switch({ fallback = null, children }) {
219
- // The Match children (marker vnodes) are static — resolve them once. The
220
- // match loop, which reads each Match's reactive `when`, must run inside a
221
- // reactive thunk (see Show/For above): components run once, so evaluating
222
- // `when()` in the body here would snapshot the active arm a single time and
223
- // `<Switch>`/`<Match when={() => sig()}>` would render once and never update.
224
- // Switch/Match are NOT lowered by the fine-grained compiler, so this runtime
225
- // path is the ONLY path — the thunk is what makes them reactive at all.
240
+ // The Match children are static, so resolve them once. The match loop, which
241
+ // reads each Match's reactive `when`, must run inside a reactive thunk (see
242
+ // Show/For above): components run once, so evaluating `when()` in the body
243
+ // here would snapshot the active arm a single time and `<Switch>`/`<Match
244
+ // when={() => sig()}>` would render once and never update.
245
+ // This is the runtime path, for h() and the automatic JSX runtime, where a
246
+ // <Match> arrives as an unexecuted marker vnode. The fine-grained compiler
247
+ // lowers <Switch> to the same conditional thunk and never reaches here; a
248
+ // <Switch> it cannot lower is a build error rather than a call into this.
226
249
  const kids = Array.isArray(children) ? children : [children];
227
250
 
228
251
  return () => {
229
252
  for (const child of kids) {
230
253
  if (child && child.tag === Match) {
231
- const condition = typeof child.props.when === 'function'
232
- ? child.props.when()
233
- : child.props.when;
234
- if (condition) {
235
- return child.children;
236
- }
254
+ const when = child.props.when;
255
+ const condition = typeof when === 'function' ? when() : when;
256
+ if (condition) return child.children;
237
257
  }
238
258
  }
239
259
  return fallback;
240
260
  };
241
261
  }
242
262
 
243
- export function Match({ when, children }) {
244
- // Match is just a marker component, Switch handles the logic
245
- return { tag: Match, props: { when }, children, _vnode: true };
263
+ export function Match(props) {
264
+ // Executed rather than left as a marker, which is what a lone compiled
265
+ // <Match> does. Returning a `{ tag: Match }` vnode here would send createDOM
266
+ // straight back into Match forever, so return a reactive thunk that renders
267
+ // the arm when it matches.
268
+ return () => {
269
+ const condition = typeof props.when === 'function' ? props.when() : props.when;
270
+ return condition ? props.children : null;
271
+ };
246
272
  }
247
273
 
248
274
  // --- Island ---
249
- // Deferred hydration component for islands architecture.
250
- // Usage: h(Island, { component: Counter, mode: 'idle' })
251
- // The babel plugin compiles <Counter client:idle /> into this.
252
-
253
- export function Island({ component: Component, mode, mediaQuery, ...props }) {
254
- const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
255
-
256
- // We need to return a vnode that the reconciler can handle.
257
- // The actual hydration scheduling happens after mount via an effect.
258
- const wrapper = signal(null);
259
- const hydrated = signal(false);
260
-
261
- function doHydrate() {
262
- if (hydrated()) return;
263
- hydrated.set(true);
264
- // Render the actual component
265
- wrapper.set(h(Component, props));
275
+ // Islands architecture: the content ships as server-rendered HTML and only the
276
+ // *interactivity* is deferred to a client trigger. The babel plugin compiles
277
+ // `<Counter client:idle />` into h(Island, { component: Counter, mode: 'idle' }).
278
+ //
279
+ // This used to render an empty marker div on every path, on the server AND the
280
+ // client, in every mode: the SSR branch never rendered the component, and the
281
+ // client branch read `hydrated()` once in a run-once component so the swap-in
282
+ // never happened. Every `client:*` directive silently deleted its component.
283
+
284
+ // Late-bound renderers, injected by render.js. components.js cannot import
285
+ // render.js directly (render -> dom -> components is already a cycle), so the
286
+ // same injection precedent as _injectGetCurrentComponent applies here.
287
+ let _islandRuntime = null;
288
+
289
+ /** @internal */
290
+ export function _injectIslandRuntime(runtime) {
291
+ _islandRuntime = runtime;
292
+ }
293
+
294
+ // Island props cross the server/client boundary as JSON, so anything that is not
295
+ // representable is dropped rather than throwing mid-render. Functions in
296
+ // particular are common (event handlers passed down) and are simply not
297
+ // transferable: the island re-creates its own handlers when it hydrates.
298
+ function serializeIslandProps(props) {
299
+ const out = {};
300
+ for (const key in props) {
301
+ const value = props[key];
302
+ if (typeof value === 'function' || typeof value === 'symbol' || value === undefined) continue;
303
+ out[key] = value;
304
+ }
305
+ try {
306
+ return JSON.stringify(out);
307
+ } catch {
308
+ return '{}';
309
+ }
310
+ }
311
+
312
+ export function Island({ component: Component, mode, mediaQuery, name, children, ...props }) {
313
+ const islandName = name || Component?.name || 'Island';
314
+ const resolvedMode = mode || 'idle';
315
+
316
+ const marker = {
317
+ 'data-island': islandName,
318
+ 'data-island-mode': resolvedMode,
319
+ 'data-hydrate': resolvedMode,
320
+ // Tells hydrateIslands() to leave this element alone: a compiler-emitted
321
+ // island holds a direct component reference and hydrates itself, so it
322
+ // needs no registry entry and must not be claimed twice.
323
+ 'data-island-self': '1',
324
+ };
325
+
326
+ // Server: render the island's HTML inline, inside the marker. An island that
327
+ // emits nothing costs SEO and LCP to buy lazy hydration, which is a strictly
328
+ // worse trade than not using the directive at all.
329
+ // Children arrive as props here but must be handed to the component
330
+ // positionally: the renderers rebuild `props.children` from the vnode's own
331
+ // children list, so anything passed through props alone is overwritten.
332
+ const childList = children == null ? [] : (Array.isArray(children) ? children : [children]);
333
+
334
+ if (isServerRender()) {
335
+ return h(
336
+ 'div',
337
+ { ...marker, 'data-island-props': serializeIslandProps(props) },
338
+ h(Component, props, ...childList)
339
+ );
340
+ }
341
+
342
+ let hydrated = false;
343
+
344
+ function hydrateInto(el) {
345
+ if (hydrated) return;
346
+ hydrated = true;
347
+
348
+ const vnode = h(Component, props, ...childList);
349
+
350
+ // Server-rendered children present => hydrate in place, reusing the DOM.
351
+ // Nothing there => a client-only render, so build it from scratch.
352
+ if (el.childNodes.length > 0 && _islandRuntime?.hydrate) {
353
+ _islandRuntime.hydrate(vnode, el);
354
+ } else if (_islandRuntime?.insert) {
355
+ _islandRuntime.insert(el, vnode, null);
356
+ }
357
+
358
+ el.removeAttribute('data-hydrate');
359
+ el.removeAttribute('data-island-self');
360
+ el.setAttribute('data-island-hydrated', '');
361
+ // Build the event in the element's own realm. A global CustomEvent belongs
362
+ // to a different realm inside an iframe or a DOM shim, and dispatchEvent
363
+ // rejects it as "not of type 'Event'".
364
+ const view = el.ownerDocument?.defaultView ?? globalThis;
365
+ if (typeof view.CustomEvent === 'function') {
366
+ el.dispatchEvent(new view.CustomEvent('island:hydrated', {
367
+ bubbles: true,
368
+ detail: { name: islandName, mode: resolvedMode },
369
+ }));
370
+ }
266
371
  }
267
372
 
268
- // Schedule hydration based on mode
269
373
  function scheduleHydration(el) {
270
- switch (mode) {
374
+ const trigger = () => hydrateInto(el);
375
+
376
+ switch (resolvedMode) {
271
377
  case 'load':
272
- queueMicrotask(doHydrate);
378
+ queueMicrotask(trigger);
273
379
  break;
274
380
 
275
381
  case 'idle':
276
- if (typeof requestIdleCallback !== 'undefined') {
277
- requestIdleCallback(doHydrate);
278
- } else {
279
- setTimeout(doHydrate, 200);
280
- }
382
+ if (typeof requestIdleCallback !== 'undefined') requestIdleCallback(trigger);
383
+ else setTimeout(trigger, 200);
281
384
  break;
282
385
 
283
386
  case 'visible': {
387
+ if (typeof IntersectionObserver === 'undefined') { queueMicrotask(trigger); break; }
284
388
  const observer = new IntersectionObserver((entries) => {
285
- if (entries[0].isIntersecting) {
389
+ if (entries.some((entry) => entry.isIntersecting)) {
286
390
  observer.disconnect();
287
- doHydrate();
391
+ trigger();
288
392
  }
289
- });
393
+ }, { rootMargin: '200px' });
290
394
  observer.observe(el);
291
395
  break;
292
396
  }
293
397
 
294
- case 'interaction': {
295
- const hydrate = () => {
296
- el.removeEventListener('click', hydrate);
297
- el.removeEventListener('focus', hydrate);
298
- el.removeEventListener('mouseenter', hydrate);
299
- doHydrate();
398
+ case 'interaction':
399
+ case 'action': {
400
+ const events = ['click', 'focus', 'mouseenter', 'touchstart'];
401
+ const onInteract = () => {
402
+ for (const type of events) el.removeEventListener(type, onInteract);
403
+ trigger();
300
404
  };
301
- el.addEventListener('click', hydrate, { once: true });
302
- el.addEventListener('focus', hydrate, { once: true });
303
- el.addEventListener('mouseenter', hydrate, { once: true });
405
+ for (const type of events) el.addEventListener(type, onInteract, { once: true });
304
406
  break;
305
407
  }
306
408
 
307
409
  case 'media': {
308
- if (!mediaQuery) { doHydrate(); break; }
410
+ if (!mediaQuery || typeof window === 'undefined' || !window.matchMedia) { trigger(); break; }
309
411
  const mq = window.matchMedia(mediaQuery);
310
- if (mq.matches) {
311
- queueMicrotask(doHydrate);
312
- } else {
313
- const checkMedia = () => {
314
- if (mq.matches) {
315
- mq.removeEventListener('change', checkMedia);
316
- doHydrate();
317
- }
318
- };
319
- mq.addEventListener('change', checkMedia);
320
- }
412
+ if (mq.matches) { queueMicrotask(trigger); break; }
413
+ const onChange = () => {
414
+ if (!mq.matches) return;
415
+ mq.removeEventListener('change', onChange);
416
+ trigger();
417
+ };
418
+ mq.addEventListener('change', onChange);
321
419
  break;
322
420
  }
323
421
 
422
+ // 'static' ships no JS at all: the server HTML is the whole island.
423
+ case 'static':
424
+ break;
425
+
324
426
  default:
325
- // Unknown mode, hydrate immediately
326
- queueMicrotask(doHydrate);
427
+ queueMicrotask(trigger);
327
428
  }
328
429
  }
329
430
 
330
- // Use ref callback to get the DOM element and schedule hydration
331
- const refCallback = (el) => {
332
- if (el) scheduleHydration(el);
333
- };
334
-
335
- // Return: show placeholder until hydrated, then show the real component
336
- return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
337
- hydrated() ? wrapper() : null
338
- );
431
+ return h('div', { ...marker, ref: (el) => { if (el) scheduleHydration(el); } });
339
432
  }
package/src/data.js CHANGED
@@ -14,6 +14,25 @@ const validatingSignals = new Map(); // key -> signal(boolean)
14
14
  const cacheTimestamps = new Map(); // key -> last access time (for LRU)
15
15
  const MAX_CACHE_SIZE = 200;
16
16
 
17
+ // --- Query key normalization ---
18
+ // Array keys are joined into the same flat string space as useSWR's string keys,
19
+ // so `useQuery({queryKey: ['todos']})` and `useSWR('todos')` share one cache
20
+ // entry by design. Every cache-facing entry point must normalize identically:
21
+ // useQuery used to be the only one that did, so `invalidateQueries(['todos'])`
22
+ // looked up a raw Array object as a Map key, found nothing, and silently did
23
+ // nothing. The documented shape was the broken one.
24
+ //
25
+ // Segments escape ':' so `['user', 'a:b']` cannot collide with
26
+ // `['user', 'a', 'b']`. A collision here serves one query's data to another,
27
+ // which is worse than a miss.
28
+ function normalizeQueryKey(key) {
29
+ if (!Array.isArray(key)) return key;
30
+ return key
31
+ .map((part) => (typeof part === 'string' ? part : JSON.stringify(part) ?? String(part)))
32
+ .map((part) => part.replace(/([\\:])/g, '\\$1'))
33
+ .join(':');
34
+ }
35
+
17
36
  function getCacheSignal(key) {
18
37
  cacheTimestamps.set(key, Date.now());
19
38
  if (!cacheSignals.has(key)) {
@@ -335,7 +354,7 @@ export function useQuery(options) {
335
354
  placeholderData,
336
355
  } = options;
337
356
 
338
- const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
357
+ const key = normalizeQueryKey(queryKey);
339
358
 
340
359
  const cacheS = getCacheSignal(key);
341
360
  const data = computed(() => {
@@ -503,7 +522,7 @@ export function useInfiniteQuery(options) {
503
522
  const isFetchingNextPage = signal(false);
504
523
  const isFetchingPreviousPage = signal(false);
505
524
 
506
- const key = Array.isArray(queryKey) ? queryKey.join(':') : queryKey;
525
+ const key = normalizeQueryKey(queryKey);
507
526
  let abortController = null;
508
527
 
509
528
  let isRefetching = false;
@@ -595,15 +614,36 @@ export function useInfiniteQuery(options) {
595
614
 
596
615
  // --- Cache Management ---
597
616
 
617
+ // Every key the cache knows about. A query that has subscribed but not yet
618
+ // resolved has a revalidation subscriber before it has a cache signal, and
619
+ // invalidating it is exactly how you tell it to fetch, so both maps count.
620
+ function allKnownKeys() {
621
+ const keys = new Set(cacheSignals.keys());
622
+ for (const key of revalidationSubscribers.keys()) keys.add(key);
623
+ return keys;
624
+ }
625
+
598
626
  export function invalidateQueries(keyOrPredicate, options = {}) {
599
- const { hard = false } = options;
627
+ const { hard = false, exact = false } = options;
600
628
  const keysToInvalidate = [];
601
629
  if (typeof keyOrPredicate === 'function') {
602
630
  for (const [key] of cacheSignals) {
603
631
  if (keyOrPredicate(key)) keysToInvalidate.push(key);
604
632
  }
633
+ } else if (Array.isArray(keyOrPredicate) && !exact) {
634
+ // An array key is a PREFIX: invalidateQueries(['todos']) invalidates
635
+ // ['todos', 1] and ['todos', {done: true}] too, which is what the shape
636
+ // implies and what every peer library does. Matching is on segment
637
+ // boundaries, so ['todo'] never matches 'todos'.
638
+ const prefix = normalizeQueryKey(keyOrPredicate);
639
+ const scoped = prefix + ':';
640
+ for (const key of allKnownKeys()) {
641
+ if (key === prefix || (typeof key === 'string' && key.startsWith(scoped))) {
642
+ keysToInvalidate.push(key);
643
+ }
644
+ }
605
645
  } else {
606
- keysToInvalidate.push(keyOrPredicate);
646
+ keysToInvalidate.push(normalizeQueryKey(keyOrPredicate));
607
647
  }
608
648
 
609
649
  for (const key of keysToInvalidate) {
@@ -619,6 +659,7 @@ export function invalidateQueries(keyOrPredicate, options = {}) {
619
659
  }
620
660
 
621
661
  export function prefetchQuery(key, fetcher) {
662
+ key = normalizeQueryKey(key);
622
663
  const cacheS = getCacheSignal(key);
623
664
  return fetcher(key).then(result => {
624
665
  cacheS.set(result);
@@ -628,6 +669,7 @@ export function prefetchQuery(key, fetcher) {
628
669
  }
629
670
 
630
671
  export function setQueryData(key, updater) {
672
+ key = normalizeQueryKey(key);
631
673
  const cacheS = getCacheSignal(key);
632
674
  const current = cacheS.peek();
633
675
  cacheS.set(typeof updater === 'function' ? updater(current) : updater);
@@ -635,6 +677,7 @@ export function setQueryData(key, updater) {
635
677
  }
636
678
 
637
679
  export function getQueryData(key) {
680
+ key = normalizeQueryKey(key);
638
681
  return cacheSignals.has(key) ? cacheSignals.get(key).peek() : undefined;
639
682
  }
640
683