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/dist/chunk-NCPX66TV.min.js +1 -0
- package/dist/chunk-RXISSKLI.min.js +11 -0
- package/dist/index.min.js +5 -15
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +30 -0
- package/jsx-dev-runtime.d.ts +2 -2
- package/jsx-runtime.d.ts +2 -2
- package/package.json +1 -1
- package/render.d.ts +15 -1
- package/src/a11y.js +34 -2
- package/src/agent-context.js +1 -1
- package/src/components.js +128 -60
- package/src/data.js +47 -4
- package/src/dom.js +50 -2
- package/src/index.js +3 -0
- package/src/render.js +26 -1
- package/src/server-context.js +12 -0
- package/testing.d.ts +36 -1
- package/dist/chunk-JIZR6JX5.min.js +0 -1
- package/dist/chunk-PABR63P3.min.js +0 -1
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}-${
|
|
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}-${
|
|
446
|
+
ids.push(`${prefix}-${nextIdSuffix()}`);
|
|
415
447
|
}
|
|
416
448
|
return ids;
|
|
417
449
|
}
|
package/src/agent-context.js
CHANGED
|
@@ -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
|
+
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
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
374
|
+
const trigger = () => hydrateInto(el);
|
|
375
|
+
|
|
376
|
+
switch (resolvedMode) {
|
|
296
377
|
case 'load':
|
|
297
|
-
queueMicrotask(
|
|
378
|
+
queueMicrotask(trigger);
|
|
298
379
|
break;
|
|
299
380
|
|
|
300
381
|
case 'idle':
|
|
301
|
-
if (typeof requestIdleCallback !== 'undefined')
|
|
302
|
-
|
|
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
|
|
389
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
311
390
|
observer.disconnect();
|
|
312
|
-
|
|
391
|
+
trigger();
|
|
313
392
|
}
|
|
314
|
-
});
|
|
393
|
+
}, { rootMargin: '200px' });
|
|
315
394
|
observer.observe(el);
|
|
316
395
|
break;
|
|
317
396
|
}
|
|
318
397
|
|
|
319
|
-
case 'interaction':
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
el.removeEventListener(
|
|
324
|
-
|
|
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(
|
|
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) {
|
|
410
|
+
if (!mediaQuery || typeof window === 'undefined' || !window.matchMedia) { trigger(); break; }
|
|
334
411
|
const mq = window.matchMedia(mediaQuery);
|
|
335
|
-
if (mq.matches) {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
351
|
-
queueMicrotask(doHydrate);
|
|
427
|
+
queueMicrotask(trigger);
|
|
352
428
|
}
|
|
353
429
|
}
|
|
354
430
|
|
|
355
|
-
|
|
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 =
|
|
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 =
|
|
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-*
|
|
981
|
-
if (key.startsWith('data-')
|
|
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'
|
|
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 });
|
package/src/server-context.js
CHANGED
|
@@ -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>;
|