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/dom.js CHANGED
@@ -18,6 +18,76 @@ const SVG_ELEMENTS = new Set([
18
18
  ]);
19
19
  const SVG_NS = 'http://www.w3.org/2000/svg';
20
20
 
21
+ // --- Attribute sanitization (shared by both setProp implementations) ---
22
+ // Attributes whose value is a URL: reject javascript:, data:, vbscript:
23
+ // protocols (case-insensitive, trimmed). xlink:href matters because SVG <a>
24
+ // executes it, and ping/object[data] are fetched by the browser.
25
+ const URL_ATTRS = new Set([
26
+ 'href', 'src', 'action', 'formaction', 'formAction',
27
+ 'data', 'ping', 'xlink:href', 'xlinkHref',
28
+ ]);
29
+
30
+ // srcdoc is entity-decoded and parsed as a document by the browser, so HTML
31
+ // escaping is not a defense. Refuse it outright rather than trying to clean it.
32
+ const REFUSED_ATTRS = new Set(['srcdoc', 'srcDoc']);
33
+
34
+ function isSafeUrl(url) {
35
+ if (url == null) return true;
36
+ // A boxed String or an object with toString() still stringifies into a live
37
+ // href, so coerce before the protocol check rather than trusting the type.
38
+ // A value with no usable toString (Object.create(null), { toString: null },
39
+ // both reachable from JSON) throws here, so refuse it rather than letting the
40
+ // TypeError abort the render.
41
+ let normalized;
42
+ try {
43
+ normalized = String(url).trim().replace(/[\s\x00-\x1f]/g, '').toLowerCase();
44
+ } catch {
45
+ return false;
46
+ }
47
+ if (normalized.startsWith('javascript:')) return false;
48
+ if (normalized.startsWith('data:')) return false;
49
+ if (normalized.startsWith('vbscript:')) return false;
50
+ return true;
51
+ }
52
+
53
+ // Event-handler prop test, case-insensitive. A case-sensitive `on` prefix lets
54
+ // `ONCLICK` fall through to setAttribute, where the browser honours it as a live
55
+ // inline handler. Shared by dom.js and render.js so the two cannot diverge.
56
+ export function _isEventProp(key) {
57
+ if (key.length <= 2) return false;
58
+ const a = key.charCodeAt(0);
59
+ const b = key.charCodeAt(1);
60
+ return (a === 111 || a === 79) && (b === 110 || b === 78);
61
+ }
62
+
63
+ // Returns true when the attribute must not be applied. Both the h()/html`` path
64
+ // (dom.js setProp) and the compiled-JSX path (render.js setProp) call this so
65
+ // they enforce identical rules.
66
+ export function _isUnsafeAttr(key, value) {
67
+ const lower = key.toLowerCase();
68
+ if (REFUSED_ATTRS.has(key) || REFUSED_ATTRS.has(lower)) return true;
69
+ if (!URL_ATTRS.has(key) && !URL_ATTRS.has(lower)) return false;
70
+ return !isSafeUrl(value);
71
+ }
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
+
21
91
  // Track all mounted component contexts for disposal
22
92
  const mountedComponents = new Set();
23
93
 
@@ -73,6 +143,20 @@ function disposeComponent(ctx) {
73
143
  mountedComponents.delete(ctx);
74
144
  }
75
145
 
146
+ // Hydration has no wrapper fragment and no comment markers to hang a component
147
+ // ctx or a reactive-child effect on, so it anchors them to the DOM node they
148
+ // produced. Without an anchor disposeTree cannot reach them and every hydrated
149
+ // component leaks its cleanups and effects.
150
+ export function addHydrationDisposer(node, fn) {
151
+ if (!node || typeof fn !== 'function') return;
152
+ if (node._hydrationDisposers) node._hydrationDisposers.push(fn);
153
+ else node._hydrationDisposers = [fn];
154
+ }
155
+
156
+ export function addHydratedComponent(node, ctx) {
157
+ addHydrationDisposer(node, () => disposeComponent(ctx));
158
+ }
159
+
76
160
  // Dispose all components and reactive effects attached to a DOM subtree.
77
161
  // Performance: checks _componentCtx / _dispose / _propEffects before walking
78
162
  // children, and only checks _commentCtxMap for comment nodes (nodeType 8).
@@ -81,6 +165,13 @@ export function disposeTree(node) {
81
165
  if (node._componentCtx) {
82
166
  disposeComponent(node._componentCtx);
83
167
  }
168
+ if (node._hydrationDisposers) {
169
+ const disposers = node._hydrationDisposers;
170
+ node._hydrationDisposers = null;
171
+ for (let i = 0; i < disposers.length; i++) {
172
+ try { disposers[i](); } catch (e) { /* already disposed */ }
173
+ }
174
+ }
84
175
  // Check comment node WeakMap for component context — only for comment nodes
85
176
  if (node.nodeType === 8) {
86
177
  const commentCtx = _commentCtxMap.get(node);
@@ -156,6 +247,13 @@ export function createDOM(vnode, parent, isSvg) {
156
247
  return frag;
157
248
  }
158
249
 
250
+ // Deferred component children (compiled JSX). The value is static (the
251
+ // factory only exists so the owning component runs before its children are
252
+ // built), so realize it in place rather than through the reactive path below.
253
+ if (typeof vnode === 'function' && vnode._lazyChildren) {
254
+ return createDOM(vnode(), parent, isSvg);
255
+ }
256
+
159
257
  // Reactive function child — use comment markers (no wrapper element)
160
258
  // to avoid polluting the DOM and breaking CSS selectors like :first-child.
161
259
  if (typeof vnode === 'function') {
@@ -170,7 +268,26 @@ export function createDOM(vnode, parent, isSvg) {
170
268
  frag.appendChild(startMarker);
171
269
  frag.appendChild(endMarker);
172
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
+
173
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 {
174
291
  const val = vnode();
175
292
  const vnodes = (val == null || val === false || val === true)
176
293
  ? []
@@ -202,6 +319,9 @@ export function createDOM(vnode, parent, isSvg) {
202
319
  }
203
320
  }
204
321
  }
322
+ } finally {
323
+ if (restoreOwner) componentStack.pop();
324
+ }
205
325
  });
206
326
 
207
327
  startMarker._dispose = dispose;
@@ -285,6 +405,69 @@ export function getComponentStack() {
285
405
  return componentStack;
286
406
  }
287
407
 
408
+ // --- _installLazyChildren(Component, target, lazyChildren) ---
409
+ // Deferred children from compiled JSX arrive as a zero-arg factory instead of
410
+ // built DOM. This defines target.children over that factory and returns a
411
+ // function that ends the current read burst (or null when there is none).
412
+ //
413
+ // Reads are cached only for the duration of one burst, i.e. the component's own
414
+ // execution. Within a burst a component that inspects its children and then
415
+ // renders them gets one array with one set of nodes, so rendering them twice
416
+ // moves them instead of duplicating them. Across bursts the cache is dropped,
417
+ // because realized children are single-use DOM: a DocumentFragment is drained
418
+ // by its first insertion and removed nodes have had their effects disposed, so
419
+ // a component that re-reads props.children from a reactive thunk must get a
420
+ // freshly built subtree rather than the corpse of the previous one.
421
+ //
422
+ // A component that establishes a scope its children depend on (a context
423
+ // provider, an error or suspense boundary) cannot use the getter at all: the
424
+ // scope only exists after the component returns, and reading props.children
425
+ // anywhere, including destructuring it in the parameter list, would build the
426
+ // subtree first. Those set _deferChildren and receive the factory itself, which
427
+ // the render paths realize once the scope exists and which a boundary
428
+ // re-invokes to rebuild its subtree on a later attempt.
429
+ export function _installLazyChildren(Component, target, lazyChildren) {
430
+ if (Component._deferChildren) {
431
+ target.children = lazyChildren;
432
+ return null;
433
+ }
434
+ let realized;
435
+ let cached = false;
436
+ let inPass = true;
437
+ Object.defineProperty(target, 'children', {
438
+ get() {
439
+ if (!inPass) return lazyChildren();
440
+ if (!cached) {
441
+ cached = true;
442
+ realized = lazyChildren();
443
+ }
444
+ return realized;
445
+ },
446
+ enumerable: true,
447
+ configurable: true,
448
+ });
449
+ return () => { inPass = false; cached = false; realized = undefined; };
450
+ }
451
+
452
+ // --- _handleNavigationSignal(error) ---
453
+ // A thrown value may carry its own handler under
454
+ // Symbol.for('what.navigation.signal'). what-router's redirect() throws one, so
455
+ // a redirect from a component body runs the navigation instead of reaching an
456
+ // ErrorBoundary, which would render error UI for a value that is not an error.
457
+ // Returns true when the value was a signal and has been handled.
458
+ //
459
+ // Both component paths route through this (createComponent below and the
460
+ // hydration branch in render.js) so the two cannot drift.
461
+ const NAV_SIGNAL = Symbol.for('what.navigation.signal');
462
+
463
+ export function _handleNavigationSignal(error) {
464
+ if (error == null) return false;
465
+ const handler = error[NAV_SIGNAL];
466
+ if (typeof handler !== 'function') return false;
467
+ handler(error);
468
+ return true;
469
+ }
470
+
288
471
  function createComponent(vnode, parent, isSvg) {
289
472
  let { tag: Component, props, children } = vnode;
290
473
 
@@ -363,6 +546,9 @@ function createComponent(vnode, parent, isSvg) {
363
546
  } else {
364
547
  mergedProps = props ? Object.assign({}, props) : {};
365
548
  }
549
+ const lazyChildren = props && props._$lazyChildren;
550
+ const endChildrenPass = lazyChildren ? _installLazyChildren(Component, mergedProps, lazyChildren) : null;
551
+
366
552
  const propsSignal = signal(mergedProps);
367
553
  ctx._propsSignal = propsSignal;
368
554
 
@@ -387,7 +573,12 @@ function createComponent(vnode, parent, isSvg) {
387
573
  result = untrack(() => Component(reactiveProps));
388
574
  } catch (error) {
389
575
  componentStack.pop();
390
- if (!reportError(error, ctx)) {
576
+ // A thrown thenable is a suspension, not a failure: hand it to the nearest
577
+ // Suspense boundary, which swaps in its fallback and re-renders on resolve.
578
+ // A navigation signal is neither: it carries its own handler.
579
+ if (!_handleNavigationSignal(error)
580
+ && !(error && typeof error.then === 'function' && suspend(error, ctx))
581
+ && !reportError(error, ctx)) {
391
582
  console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
392
583
  throw error;
393
584
  }
@@ -396,8 +587,10 @@ function createComponent(vnode, parent, isSvg) {
396
587
  container.appendChild(endComment);
397
588
  return container;
398
589
  }
590
+ // The component has run; anything that reads props.children from here on is a
591
+ // later render pass and must build its own children.
592
+ if (endChildrenPass) endChildrenPass();
399
593
 
400
- componentStack.pop();
401
594
  ctx.mounted = true;
402
595
 
403
596
  // Run onMount callbacks after DOM is ready
@@ -411,17 +604,38 @@ function createComponent(vnode, parent, isSvg) {
411
604
  }
412
605
 
413
606
  // Build fragment: <!-- c:start --> [component output] <!-- c:end -->
607
+ // ctx stays on the stack while children are realized so that a child's
608
+ // parentCtx (and therefore useContext / error-boundary lookup) resolves to
609
+ // this component rather than to whatever rendered it.
414
610
  container.appendChild(startComment);
415
611
  const vnodes = Array.isArray(result) ? result : [result];
416
- for (const v of vnodes) {
417
- const node = createDOM(v, container, isSvg);
418
- if (node) container.appendChild(node);
612
+ try {
613
+ for (const v of vnodes) {
614
+ const node = createDOM(v, container, isSvg);
615
+ if (node) container.appendChild(node);
616
+ }
617
+ } finally {
618
+ componentStack.pop();
419
619
  }
420
620
  container.appendChild(endComment);
421
621
 
422
622
  return container;
423
623
  }
424
624
 
625
+ // Walk up from ctx to the nearest Suspense boundary and notify it. Returns
626
+ // false when nothing in the chain can handle the suspension.
627
+ function suspend(promise, ctx) {
628
+ let c = ctx;
629
+ while (c) {
630
+ if (c._suspenseBoundary) {
631
+ c._suspenseBoundary.onSuspend(promise);
632
+ return true;
633
+ }
634
+ c = c._parentCtx;
635
+ }
636
+ return false;
637
+ }
638
+
425
639
  // Error boundary component handler
426
640
  function createErrorBoundary(vnode, parent) {
427
641
  const { errorState, handleError, fallback, reset } = vnode.props;
@@ -504,6 +718,7 @@ function createSuspenseBoundary(vnode, parent) {
504
718
  hooks: [], hookIndex: 0, effects: [], cleanups: [],
505
719
  mounted: false, disposed: false,
506
720
  _parentCtx: componentStack[componentStack.length - 1] || null,
721
+ _suspenseBoundary: boundary,
507
722
  _startComment: startComment,
508
723
  _endComment: endComment,
509
724
  };
@@ -514,10 +729,16 @@ function createSuspenseBoundary(vnode, parent) {
514
729
  container.appendChild(startComment);
515
730
  container.appendChild(endComment);
516
731
 
732
+ // A child suspending mid-render flips `loading` while this effect is still
733
+ // running, which can re-enter it. The generation counter lets the outer run
734
+ // detect that a newer run already replaced the content and bail out.
735
+ let generation = 0;
736
+
517
737
  const dispose = effect(() => {
518
738
  const isLoading = loading();
519
739
  const vnodes = isLoading ? [fallback] : children;
520
740
  const normalized = Array.isArray(vnodes) ? vnodes : [vnodes];
741
+ const gen = ++generation;
521
742
 
522
743
  componentStack.push(boundaryCtx);
523
744
 
@@ -530,20 +751,26 @@ function createSuspenseBoundary(vnode, parent) {
530
751
  }
531
752
  }
532
753
 
533
- for (const v of normalized) {
534
- const node = createDOM(v, parent);
535
- if (node) {
536
- // Insert before endComment
537
- if (endComment.parentNode) {
538
- endComment.parentNode.insertBefore(node, endComment);
539
- } else {
540
- // Still in fragment before first mount
541
- container.insertBefore(node, endComment);
754
+ try {
755
+ for (const v of normalized) {
756
+ const node = createDOM(v, parent);
757
+ if (gen !== generation) {
758
+ if (node) disposeTree(node);
759
+ break;
760
+ }
761
+ if (node) {
762
+ // Insert before endComment
763
+ if (endComment.parentNode) {
764
+ endComment.parentNode.insertBefore(node, endComment);
765
+ } else {
766
+ // Still in fragment before first mount
767
+ container.insertBefore(node, endComment);
768
+ }
542
769
  }
543
770
  }
771
+ } finally {
772
+ componentStack.pop();
544
773
  }
545
-
546
- componentStack.pop();
547
774
  });
548
775
 
549
776
  boundaryCtx.effects.push(dispose);
@@ -653,13 +880,14 @@ export function _setSelectValue(el, value) {
653
880
  // legacy diff-driven update path.
654
881
  // - render.js setProp — fine-grained-compiler output path. No event-handler
655
882
  // bookkeeping (events go through delegation / direct addEventListener at
656
- // compile time), but adds URL sanitization for href/src and the
657
- // innerHTML `{__html}` enforcement that the compiler relies on.
658
- // Both share the `el._propEffects[key]` disposer convention. Do not merge
659
- // without consolidating the event/listener model they have different callers.
883
+ // compile time), but adds the innerHTML `{__html}` enforcement that the
884
+ // compiler relies on.
885
+ // Both share the `el._propEffects[key]` disposer convention and both gate
886
+ // attributes through _isUnsafeAttr() so URL sanitization cannot diverge. Do not
887
+ // merge without consolidating the event/listener model: they have different callers.
660
888
  function setProp(el, key, value, isSvg) {
661
889
  // Reactive function props — wrap in effect for fine-grained updates
662
- if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
890
+ if (typeof value === 'function' && !_isEventProp(key) && key !== 'ref') {
663
891
  if (!el._propEffects) el._propEffects = {};
664
892
  if (el._propEffects[key]) {
665
893
  try { el._propEffects[key](); } catch (e) { /* already disposed */ }
@@ -672,7 +900,8 @@ function setProp(el, key, value, isSvg) {
672
900
  }
673
901
 
674
902
  // Event handlers
675
- if (key.startsWith('on') && key.length > 2) {
903
+ if (_isEventProp(key)) {
904
+ if (typeof value !== 'function' && value != null) return;
676
905
  let eventName = key.slice(2);
677
906
  let useCapture = false;
678
907
  if (eventName.endsWith('Capture')) {
@@ -700,6 +929,14 @@ function setProp(el, key, value, isSvg) {
700
929
  return;
701
930
  }
702
931
 
932
+ // Reject dangerous URL protocols and srcdoc
933
+ if (_isUnsafeAttr(key, value)) {
934
+ if (typeof console !== 'undefined') {
935
+ console.warn(`[what] Blocked unsafe URL in "${key}" attribute:`, value);
936
+ }
937
+ return;
938
+ }
939
+
703
940
  // className / class
704
941
  if (key === 'className' || key === 'class') {
705
942
  if (isSvg) {
@@ -773,6 +1010,14 @@ function setProp(el, key, value, isSvg) {
773
1010
  return;
774
1011
  }
775
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
+
776
1021
  // Boolean attributes
777
1022
  if (typeof value === 'boolean') {
778
1023
  if (value) el.setAttribute(key, '');
@@ -780,8 +1025,8 @@ function setProp(el, key, value, isSvg) {
780
1025
  return;
781
1026
  }
782
1027
 
783
- // data-* and aria-*
784
- if (key.startsWith('data-') || key.startsWith('aria-')) {
1028
+ // data-*
1029
+ if (key.startsWith('data-')) {
785
1030
  el.setAttribute(key, value);
786
1031
  return;
787
1032
  }
package/src/errors.js CHANGED
@@ -128,6 +128,35 @@ html\`<div>\${sanitizedContent}</div>\``,
128
128
  // Good — stable key:
129
129
  <For each={items()}>{item => <li key={item.id}>{item.name}</li>}</For>`,
130
130
  },
131
+
132
+ UNSAFE_REDIRECT: {
133
+ code: 'ERR_UNSAFE_REDIRECT',
134
+ severity: 'error',
135
+ template: 'redirect() refused an unsafe target: {{target}}.',
136
+ suggestion: 'redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.',
137
+ codeExample: `// Bad - a user-controlled target can leave your origin:
138
+ redirect(query.next);
139
+
140
+ // Good - allowlist the target first:
141
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`,
142
+ },
143
+
144
+ REDIRECT_NOT_CAUGHT: {
145
+ code: 'ERR_REDIRECT_NOT_CAUGHT',
146
+ severity: 'error',
147
+ template: 'A redirect() to "{{target}}" surfaced uncaught, so nothing performed the navigation.',
148
+ suggestion: 'redirect() is caught in route middleware and in a component body. From an event handler, a promise callback, a timer or a reactive thunk, call navigate(to) instead. If the call is inside a try/catch, rethrow anything whose name is RouterRedirect. On the server this signal escapes renderToString to its caller: read its `to` and emit a 302 rather than calling navigate().',
149
+ codeExample: `// Bad - a reactive thunk re-runs outside the render the Router caught:
150
+ <div>{() => (loggedOut() ? redirect('/login') : <Dashboard />)}</div>
151
+
152
+ // Good - navigate() from a thunk or a handler:
153
+ <div>{() => (loggedOut() ? (navigate('/login'), null) : <Dashboard />)}</div>
154
+ <button onclick={() => navigate('/login')}>Sign in</button>
155
+
156
+ // On the server, catch it instead of navigating:
157
+ try { html = renderToString(<App />); }
158
+ catch (e) { if (e.name === 'RouterRedirect') return Response.redirect(e.to, 302); throw e; }`,
159
+ },
131
160
  };
132
161
 
133
162
  // --- WhatError ---
package/src/head.js CHANGED
@@ -85,11 +85,29 @@ export function endHeadCollection(sink) {
85
85
  return out;
86
86
  }
87
87
 
88
+ // Head attributes usually come from CMS/SEO data, so attribute NAMES must be
89
+ // validated, not merely escaped: a key of `x" onload="alert(1)` would otherwise
90
+ // break out of the quoted value and inject a live handler. Mirrors
91
+ // what-server's SAFE_ATTR_NAME, plus a case-insensitive `on*` refusal so
92
+ // `onLoad` cannot slip past an uppercase-tolerant name pattern.
93
+ const SAFE_ATTR_NAME = /^[a-zA-Z_:][a-zA-Z0-9:._-]*$/;
94
+
95
+ function isSafeAttrName(name) {
96
+ if (!SAFE_ATTR_NAME.test(name)) return false;
97
+ const lower = name.toLowerCase();
98
+ return !(lower.startsWith('on') && lower.length > 2);
99
+ }
100
+
101
+ function attrName(key) {
102
+ return key === 'httpEquiv' ? 'http-equiv' : key;
103
+ }
104
+
88
105
  function renderHeadTag(tag, attrs) {
89
106
  let s = `<${tag}`;
90
107
  for (const [k, v] of Object.entries(attrs)) {
91
108
  if (v == null || v === false) continue;
92
- const name = k === 'httpEquiv' ? 'http-equiv' : k;
109
+ const name = attrName(k);
110
+ if (!isSafeAttrName(name)) continue;
93
111
  s += ` ${name}="${escapeHtml(String(v))}"`;
94
112
  }
95
113
  return s + '>';
@@ -106,8 +124,17 @@ function escapeHtml(str) {
106
124
 
107
125
  // --- Client DOM helpers ---
108
126
 
127
+ // Dedup keys come from user-supplied attrs (and fall back to JSON.stringify,
128
+ // which always contains quotes), so they cannot go into a selector raw: an
129
+ // unescaped one throws DOMException and takes all head management with it.
130
+ function escapeSelectorValue(key) {
131
+ const s = String(key);
132
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
133
+ return s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
134
+ }
135
+
109
136
  function setHeadTag(tag, key, attrs) {
110
- const existing = document.head.querySelector(`[data-what-head="${key}"]`);
137
+ const existing = document.head.querySelector(`[data-what-head="${escapeSelectorValue(key)}"]`);
111
138
  if (existing) {
112
139
  updateElement(existing, attrs);
113
140
  return;
@@ -116,15 +143,18 @@ function setHeadTag(tag, key, attrs) {
116
143
  const el = document.createElement(tag);
117
144
  el.setAttribute('data-what-head', key);
118
145
  for (const [k, v] of Object.entries(attrs)) {
119
- el.setAttribute(k, v);
146
+ if (!isSafeAttrName(attrName(k))) continue;
147
+ el.setAttribute(attrName(k), v);
120
148
  }
121
149
  document.head.appendChild(el);
122
150
  }
123
151
 
124
152
  function updateElement(el, attrs) {
125
153
  for (const [k, v] of Object.entries(attrs)) {
126
- if (el.getAttribute(k) !== v) {
127
- el.setAttribute(k, v);
154
+ const name = attrName(k);
155
+ if (!isSafeAttrName(name)) continue;
156
+ if (el.getAttribute(name) !== v) {
157
+ el.setAttribute(name, v);
128
158
  }
129
159
  }
130
160
  }
package/src/hooks.js CHANGED
@@ -282,6 +282,9 @@ export function createContext(defaultValue) {
282
282
  return typeof children === 'function' ? children(value) : children;
283
283
  },
284
284
  };
285
+ // The context value is only published once the provider body runs, so
286
+ // compiled children must not be built during this call. See createComponent.
287
+ context.Provider._deferChildren = true;
285
288
  return context;
286
289
  }
287
290
 
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/reactive.js CHANGED
@@ -65,8 +65,7 @@ let iterativeEvalStack = null; // array when inside evaluation loop, null other
65
65
  // - No rest args (...args) — uses arguments.length for zero-alloc read path
66
66
  // - Subscriber tracking uses lastTracked to skip redundant Set.add/Array.push
67
67
  // when the same signal is read multiple times in one effect (common pattern)
68
- // - Write path uses === first (fast for primitives), falls back to Object.is
69
- // only for NaN detection
68
+ // - Write path inlines Object.is so NaN and -0/+0 match memo() without a call
70
69
  // - subs.size check avoids notify() call when no subscribers
71
70
 
72
71
  export function signal(initial, debugName) {
@@ -89,9 +88,10 @@ export function signal(initial, debugName) {
89
88
  );
90
89
  }
91
90
  const nextVal = typeof next === 'function' ? next(value) : next;
92
- // Fast equality: === handles all primitives except NaN.
93
- // Only fall through for the NaN !== NaN case.
94
- if (value === nextVal || (value !== value && nextVal !== nextVal)) return;
91
+ // Object.is semantics, inlined: the common case (values differ) exits on the
92
+ // first comparison instead of paying a call. memo() uses Object.is too, so
93
+ // signals and memos agree on NaN and on -0 vs +0.
94
+ if (value === nextVal ? value !== 0 || 1 / value === 1 / nextVal : value !== value && nextVal !== nextVal) return;
95
95
  value = nextVal;
96
96
  // Invalidate lastTracked since value changed — any effect that reads
97
97
  // this signal during re-run needs to re-track.