what-core 0.11.8 → 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/a11y.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { signal, effect } from './reactive.js';
5
5
  import { h } from './h.js';
6
6
  import { getCurrentComponent } from './dom.js';
7
+ import { getServerContext } from './server-context.js';
7
8
 
8
9
  // --- Focus Management ---
9
10
 
@@ -401,17 +402,48 @@ export function LiveRegion({ children, priority = 'polite', atomic = true }) {
401
402
  // --- ID Generator ---
402
403
  // Generate unique IDs for ARIA attributes
403
404
 
405
+ // The counter is render-scoped on the server and module-global on the client.
406
+ //
407
+ // A bare module-global counter is wrong on the server in two independent ways.
408
+ // Ids drift between the SSR pass and hydration, which breaks exactly the
409
+ // relationships useId exists to create (`for`/`id`, `aria-labelledby`,
410
+ // `aria-describedby`), and concurrent requests interleave into each other's
411
+ // sequence, so two visitors can be served HTML whose ids were allocated in the
412
+ // order the event loop happened to run. Every framework in the cohort ships this
413
+ // primitive as SSR-stable because that is the whole point of shipping it.
414
+ //
415
+ // getServerContext() is the render-scoped store the SSR keystone already
416
+ // maintains (AsyncLocalStorage-backed in Node), so this is wiring rather than
417
+ // new machinery, and it adds no public API.
404
418
  let idCounter = 0;
405
419
 
420
+ function nextIdSuffix() {
421
+ const ctx = getServerContext();
422
+ if (ctx) {
423
+ ctx.idCounter = (ctx.idCounter || 0) + 1;
424
+ return ctx.idCounter;
425
+ }
426
+ return ++idCounter;
427
+ }
428
+
429
+ /**
430
+ * Reset the client-side counter. Called at the start of hydration so the client
431
+ * reproduces the server's id sequence instead of continuing past it.
432
+ * @internal
433
+ */
434
+ export function __resetIdCounter() {
435
+ idCounter = 0;
436
+ }
437
+
406
438
  export function useId(prefix = 'what') {
407
- const id = `${prefix}-${++idCounter}`;
439
+ const id = `${prefix}-${nextIdSuffix()}`;
408
440
  return () => id;
409
441
  }
410
442
 
411
443
  export function useIds(count, prefix = 'what') {
412
444
  const ids = [];
413
445
  for (let i = 0; i < count; i++) {
414
- ids.push(`${prefix}-${++idCounter}`);
446
+ ids.push(`${prefix}-${nextIdSuffix()}`);
415
447
  }
416
448
  return ids;
417
449
  }
@@ -8,7 +8,7 @@ import { getCollectedErrors } from './errors.js';
8
8
  // --- Version ---
9
9
  // Keep in sync with packages/core/package.json (checked by
10
10
  // core/test/guardrails.test.js so it can't silently go stale again).
11
- const VERSION = '0.11.8';
11
+ const VERSION = '0.12.0';
12
12
 
13
13
  // --- Component Registry ---
14
14
  // Tracks mounted components for agent inspection.
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.
@@ -271,94 +272,161 @@ export function Match(props) {
271
272
  }
272
273
 
273
274
  // --- Island ---
274
- // Deferred hydration component for islands architecture.
275
- // Usage: h(Island, { component: Counter, mode: 'idle' })
276
- // The babel plugin compiles <Counter client:idle /> into this.
277
-
278
- export function Island({ component: Component, mode, mediaQuery, ...props }) {
279
- const placeholder = h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode });
280
-
281
- // We need to return a vnode that the reconciler can handle.
282
- // The actual hydration scheduling happens after mount via an effect.
283
- const wrapper = signal(null);
284
- const hydrated = signal(false);
285
-
286
- function doHydrate() {
287
- if (hydrated()) return;
288
- hydrated.set(true);
289
- // Render the actual component
290
- 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
+ }
291
371
  }
292
372
 
293
- // Schedule hydration based on mode
294
373
  function scheduleHydration(el) {
295
- switch (mode) {
374
+ const trigger = () => hydrateInto(el);
375
+
376
+ switch (resolvedMode) {
296
377
  case 'load':
297
- queueMicrotask(doHydrate);
378
+ queueMicrotask(trigger);
298
379
  break;
299
380
 
300
381
  case 'idle':
301
- if (typeof requestIdleCallback !== 'undefined') {
302
- requestIdleCallback(doHydrate);
303
- } else {
304
- setTimeout(doHydrate, 200);
305
- }
382
+ if (typeof requestIdleCallback !== 'undefined') requestIdleCallback(trigger);
383
+ else setTimeout(trigger, 200);
306
384
  break;
307
385
 
308
386
  case 'visible': {
387
+ if (typeof IntersectionObserver === 'undefined') { queueMicrotask(trigger); break; }
309
388
  const observer = new IntersectionObserver((entries) => {
310
- if (entries[0].isIntersecting) {
389
+ if (entries.some((entry) => entry.isIntersecting)) {
311
390
  observer.disconnect();
312
- doHydrate();
391
+ trigger();
313
392
  }
314
- });
393
+ }, { rootMargin: '200px' });
315
394
  observer.observe(el);
316
395
  break;
317
396
  }
318
397
 
319
- case 'interaction': {
320
- const hydrate = () => {
321
- el.removeEventListener('click', hydrate);
322
- el.removeEventListener('focus', hydrate);
323
- el.removeEventListener('mouseenter', hydrate);
324
- 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();
325
404
  };
326
- el.addEventListener('click', hydrate, { once: true });
327
- el.addEventListener('focus', hydrate, { once: true });
328
- el.addEventListener('mouseenter', hydrate, { once: true });
405
+ for (const type of events) el.addEventListener(type, onInteract, { once: true });
329
406
  break;
330
407
  }
331
408
 
332
409
  case 'media': {
333
- if (!mediaQuery) { doHydrate(); break; }
410
+ if (!mediaQuery || typeof window === 'undefined' || !window.matchMedia) { trigger(); break; }
334
411
  const mq = window.matchMedia(mediaQuery);
335
- if (mq.matches) {
336
- queueMicrotask(doHydrate);
337
- } else {
338
- const checkMedia = () => {
339
- if (mq.matches) {
340
- mq.removeEventListener('change', checkMedia);
341
- doHydrate();
342
- }
343
- };
344
- mq.addEventListener('change', checkMedia);
345
- }
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);
346
419
  break;
347
420
  }
348
421
 
422
+ // 'static' ships no JS at all: the server HTML is the whole island.
423
+ case 'static':
424
+ break;
425
+
349
426
  default:
350
- // Unknown mode, hydrate immediately
351
- queueMicrotask(doHydrate);
427
+ queueMicrotask(trigger);
352
428
  }
353
429
  }
354
430
 
355
- // Use ref callback to get the DOM element and schedule hydration
356
- const refCallback = (el) => {
357
- if (el) scheduleHydration(el);
358
- };
359
-
360
- // Return: show placeholder until hydrated, then show the real component
361
- return h('div', { 'data-island': Component.name || 'Island', 'data-hydrate': mode, ref: refCallback },
362
- hydrated() ? wrapper() : null
363
- );
431
+ return h('div', { ...marker, ref: (el) => { if (el) scheduleHydration(el); } });
364
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
 
package/src/dom.js CHANGED
@@ -70,6 +70,24 @@ export function _isUnsafeAttr(key, value) {
70
70
  return !isSafeUrl(value);
71
71
  }
72
72
 
73
+ // ARIA attributes and `role` take ENUMERATED string values, never HTML boolean
74
+ // syntax. `aria-checked=""` is not a valid value, and an ABSENT `aria-expanded`
75
+ // means something different from `aria-expanded="false"` (unsupported versus
76
+ // collapsed) to assistive technology.
77
+ //
78
+ // This is shared because the three render paths disagreed. The client
79
+ // (dom.js setProp) hit a generic `typeof value === 'boolean'` branch before it
80
+ // ever reached its aria branch, so it emitted `aria-checked=""` for true and
81
+ // removed the attribute for false. The server special-cased `true` correctly but
82
+ // skipped every falsy value earlier in the loop, so it dropped `false` entirely.
83
+ // So SSR emitted valid ARIA and the first client update silently corrupted it,
84
+ // while `aria-*={false}` was wrong everywhere. Every widget built on the a11y
85
+ // module is affected, since useAriaExpanded/useAriaSelected/useAriaChecked all
86
+ // return booleans.
87
+ export function _isAriaAttr(key) {
88
+ return key === 'role' || key.startsWith('aria-');
89
+ }
90
+
73
91
  // Track all mounted component contexts for disposal
74
92
  const mountedComponents = new Set();
75
93
 
@@ -250,7 +268,26 @@ export function createDOM(vnode, parent, isSvg) {
250
268
  frag.appendChild(startMarker);
251
269
  frag.appendChild(endMarker);
252
270
 
271
+ // Capture the owning component at CREATION time.
272
+ //
273
+ // This effect re-runs long after the synchronous render that created it,
274
+ // when the component stack is empty. Everything it builds on a re-run
275
+ // therefore got `parentCtx = null`, severing the owner chain, and the two
276
+ // things that walk that chain both went blind:
277
+ // - suspend() found no Suspense boundary, so a lazy() component reached by
278
+ // a signal update (any client-side navigation) threw its pending promise
279
+ // as an uncaught error and left the region permanently empty.
280
+ // - the ErrorBoundary lookup found nothing, so a throw from a component
281
+ // rendered after any state change escaped the boundary wrapping it.
282
+ // Both worked on first paint and only failed once the app was interactive.
283
+ const owner = componentStack[componentStack.length - 1] || null;
284
+
253
285
  const dispose = effect(() => {
286
+ // Already on top during the initial synchronous run; only re-push when the
287
+ // stack has since unwound.
288
+ const restoreOwner = owner !== null && componentStack[componentStack.length - 1] !== owner;
289
+ if (restoreOwner) componentStack.push(owner);
290
+ try {
254
291
  const val = vnode();
255
292
  const vnodes = (val == null || val === false || val === true)
256
293
  ? []
@@ -282,6 +319,9 @@ export function createDOM(vnode, parent, isSvg) {
282
319
  }
283
320
  }
284
321
  }
322
+ } finally {
323
+ if (restoreOwner) componentStack.pop();
324
+ }
285
325
  });
286
326
 
287
327
  startMarker._dispose = dispose;
@@ -970,6 +1010,14 @@ function setProp(el, key, value, isSvg) {
970
1010
  return;
971
1011
  }
972
1012
 
1013
+ // aria-*/role BEFORE the boolean fast-path: these are enumerated string
1014
+ // attributes, so a boolean has to serialize as "true"/"false", never as HTML
1015
+ // boolean syntax. See _isAriaAttr.
1016
+ if (_isAriaAttr(key)) {
1017
+ el.setAttribute(key, typeof value === 'boolean' ? String(value) : value);
1018
+ return;
1019
+ }
1020
+
973
1021
  // Boolean attributes
974
1022
  if (typeof value === 'boolean') {
975
1023
  if (value) el.setAttribute(key, '');
@@ -977,8 +1025,8 @@ function setProp(el, key, value, isSvg) {
977
1025
  return;
978
1026
  }
979
1027
 
980
- // data-* and aria-*
981
- if (key.startsWith('data-') || key.startsWith('aria-')) {
1028
+ // data-*
1029
+ if (key.startsWith('data-')) {
982
1030
  el.setAttribute(key, value);
983
1031
  return;
984
1032
  }
package/src/index.js CHANGED
@@ -14,6 +14,9 @@ export { h, Fragment, html } from './h.js';
14
14
 
15
15
  // DOM mounting & rendering (fine-grained, no VDOM reconciler)
16
16
  export { mount } from './dom.js';
17
+ // Internal, underscore-prefixed: shared so the client, compiled-JSX and SSR
18
+ // attribute paths cannot disagree about ARIA serialization again.
19
+ export { _isAriaAttr } from './dom.js';
17
20
 
18
21
  // Hooks (React-compatible API)
19
22
  export {
package/src/render.js CHANGED
@@ -3,7 +3,9 @@
3
3
  // No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
4
4
 
5
5
  import { effect, untrack, createRoot, _createItemScope, signal, memo, __DEV__ } from './reactive.js';
6
+ import { __resetIdCounter } from './a11y.js';
6
7
  import { createDOM, disposeTree, getCurrentComponent, getComponentStack, addHydrationDisposer, addHydratedComponent, _setSelectValue, _isUnsafeAttr, _isEventProp, _installLazyChildren, _handleNavigationSignal } from './dom.js';
8
+ import { _injectIslandRuntime } from './components.js';
7
9
  export { effect, untrack };
8
10
  // Re-export memo for compiled output (branch memoization: the compiler emits
9
11
  // _$memo(() => cond) so conditional branches only re-create DOM when the
@@ -1596,6 +1598,12 @@ export function isHydrating() {
1596
1598
  */
1597
1599
  export function hydrate(vnode, container) {
1598
1600
  _isHydrating = true;
1601
+ // Restart the useId sequence so the client reproduces the server's ids rather
1602
+ // than continuing past them. The server allocates from a render-scoped counter
1603
+ // starting at 1; without this reset any client-side useId call made before
1604
+ // hydration would shift every id and break the `for`/`aria-labelledby`
1605
+ // relationships the primitive exists to create.
1606
+ __resetIdCounter();
1599
1607
  _hydrationCursor = { parent: container, index: 0 };
1600
1608
 
1601
1609
  try {
@@ -1843,9 +1851,20 @@ function hydrateNode(vnode, parent) {
1843
1851
  */
1844
1852
  function hydrateElementProps(el, props) {
1845
1853
  for (const key in props) {
1846
- if (key === 'children' || key === 'key' || key === 'ref') continue;
1854
+ if (key === 'children' || key === 'key') continue;
1847
1855
  if (key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
1848
1856
 
1857
+ // Refs must fire on the hydration path too. Skipping them meant every
1858
+ // component that reaches for its own DOM node through a ref got nothing
1859
+ // under SSR while working fine in a client-only render, which is the
1860
+ // hardest class of bug to find: it only reproduces in production.
1861
+ if (key === 'ref') {
1862
+ const ref = props.ref;
1863
+ if (typeof ref === 'function') ref(el);
1864
+ else if (ref && typeof ref === 'object') ref.current = el;
1865
+ continue;
1866
+ }
1867
+
1849
1868
  const value = props[key];
1850
1869
 
1851
1870
  // Event handlers — always attach (they don't exist in SSR HTML)
@@ -1880,3 +1899,9 @@ function hydrateElementProps(el, props) {
1880
1899
  if (key === 'data-hk') continue;
1881
1900
  }
1882
1901
  }
1902
+
1903
+ // Islands hydrate themselves against their own DOM element, which needs both the
1904
+ // hydration walker and the insert path. components.js is upstream of this module
1905
+ // (render -> dom -> components), so the renderers are handed down rather than
1906
+ // imported back up, matching _injectGetCurrentComponent.
1907
+ _injectIslandRuntime({ hydrate, insert });
@@ -33,6 +33,18 @@ export function getServerContext() {
33
33
  return _asyncContextStorage?.getStore() ?? _current;
34
34
  }
35
35
 
36
+ /**
37
+ * True while a server render is in progress.
38
+ *
39
+ * Prefer this over a bare `typeof document === 'undefined'` check. That test asks
40
+ * "is there a DOM?" when the question is "am I rendering to a string?", and the
41
+ * two answers diverge under every DOM shim (jsdom, happy-dom, a Workers
42
+ * polyfill) and in any test that renders both ways in one process.
43
+ */
44
+ export function isServerRender() {
45
+ return typeof document === 'undefined' || getServerContext() != null;
46
+ }
47
+
36
48
  /**
37
49
  * Set the active context. Returns the PREVIOUS context so callers can restore it
38
50
  * manually (runWithServerContext does this for you).
package/testing.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // What Framework - Testing Utilities Type Definitions
2
2
 
3
- import { VNode, Signal } from './index';
3
+ import { VNode, Signal } from './index.js';
4
4
 
5
5
  // Setup and Cleanup
6
6
  export function setupDOM(): HTMLElement | null;
@@ -101,3 +101,38 @@ export interface Screen {
101
101
  }
102
102
 
103
103
  export const screen: Screen;
104
+
105
+ // --- renderTest ---
106
+ // Render a component and expose the signals it created by debug name, so a test
107
+ // can drive state directly instead of going through the DOM.
108
+
109
+ export interface RenderTestResult {
110
+ container: HTMLElement;
111
+ /** Signals created during the component's single run, keyed by debug name. */
112
+ signals: Record<string, Signal<any>>;
113
+ /** Flush pending effects synchronously. */
114
+ update(): void;
115
+ unmount(): void;
116
+ }
117
+
118
+ export function renderTest<P = {}>(Component: (props: P) => any, props?: P): RenderTestResult;
119
+
120
+ /** Run every pending effect synchronously, so assertions see settled DOM. */
121
+ export function flushEffects(): void;
122
+
123
+ /** Record which signals a callback reads and writes, by debug name. */
124
+ export function trackSignals(fn: () => void): { accessed: string[]; written: string[] };
125
+
126
+ // --- mockSignal ---
127
+ // A signal that records every distinct value it has held.
128
+
129
+ export interface MockSignal<T> extends Signal<T> {
130
+ /** Every distinct value, oldest first, starting with the initial value. */
131
+ readonly history: T[];
132
+ /** How many times the value actually changed (equal writes do not count). */
133
+ readonly setCount: number;
134
+ /** Restore the initial value (or `value`) and clear the history. */
135
+ reset(value?: T): void;
136
+ }
137
+
138
+ export function mockSignal<T>(name: string, initialValue: T): MockSignal<T>;