what-core 0.11.7 → 0.11.8

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,58 @@ 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
+
21
73
  // Track all mounted component contexts for disposal
22
74
  const mountedComponents = new Set();
23
75
 
@@ -73,6 +125,20 @@ function disposeComponent(ctx) {
73
125
  mountedComponents.delete(ctx);
74
126
  }
75
127
 
128
+ // Hydration has no wrapper fragment and no comment markers to hang a component
129
+ // ctx or a reactive-child effect on, so it anchors them to the DOM node they
130
+ // produced. Without an anchor disposeTree cannot reach them and every hydrated
131
+ // component leaks its cleanups and effects.
132
+ export function addHydrationDisposer(node, fn) {
133
+ if (!node || typeof fn !== 'function') return;
134
+ if (node._hydrationDisposers) node._hydrationDisposers.push(fn);
135
+ else node._hydrationDisposers = [fn];
136
+ }
137
+
138
+ export function addHydratedComponent(node, ctx) {
139
+ addHydrationDisposer(node, () => disposeComponent(ctx));
140
+ }
141
+
76
142
  // Dispose all components and reactive effects attached to a DOM subtree.
77
143
  // Performance: checks _componentCtx / _dispose / _propEffects before walking
78
144
  // children, and only checks _commentCtxMap for comment nodes (nodeType 8).
@@ -81,6 +147,13 @@ export function disposeTree(node) {
81
147
  if (node._componentCtx) {
82
148
  disposeComponent(node._componentCtx);
83
149
  }
150
+ if (node._hydrationDisposers) {
151
+ const disposers = node._hydrationDisposers;
152
+ node._hydrationDisposers = null;
153
+ for (let i = 0; i < disposers.length; i++) {
154
+ try { disposers[i](); } catch (e) { /* already disposed */ }
155
+ }
156
+ }
84
157
  // Check comment node WeakMap for component context — only for comment nodes
85
158
  if (node.nodeType === 8) {
86
159
  const commentCtx = _commentCtxMap.get(node);
@@ -156,6 +229,13 @@ export function createDOM(vnode, parent, isSvg) {
156
229
  return frag;
157
230
  }
158
231
 
232
+ // Deferred component children (compiled JSX). The value is static (the
233
+ // factory only exists so the owning component runs before its children are
234
+ // built), so realize it in place rather than through the reactive path below.
235
+ if (typeof vnode === 'function' && vnode._lazyChildren) {
236
+ return createDOM(vnode(), parent, isSvg);
237
+ }
238
+
159
239
  // Reactive function child — use comment markers (no wrapper element)
160
240
  // to avoid polluting the DOM and breaking CSS selectors like :first-child.
161
241
  if (typeof vnode === 'function') {
@@ -285,6 +365,69 @@ export function getComponentStack() {
285
365
  return componentStack;
286
366
  }
287
367
 
368
+ // --- _installLazyChildren(Component, target, lazyChildren) ---
369
+ // Deferred children from compiled JSX arrive as a zero-arg factory instead of
370
+ // built DOM. This defines target.children over that factory and returns a
371
+ // function that ends the current read burst (or null when there is none).
372
+ //
373
+ // Reads are cached only for the duration of one burst, i.e. the component's own
374
+ // execution. Within a burst a component that inspects its children and then
375
+ // renders them gets one array with one set of nodes, so rendering them twice
376
+ // moves them instead of duplicating them. Across bursts the cache is dropped,
377
+ // because realized children are single-use DOM: a DocumentFragment is drained
378
+ // by its first insertion and removed nodes have had their effects disposed, so
379
+ // a component that re-reads props.children from a reactive thunk must get a
380
+ // freshly built subtree rather than the corpse of the previous one.
381
+ //
382
+ // A component that establishes a scope its children depend on (a context
383
+ // provider, an error or suspense boundary) cannot use the getter at all: the
384
+ // scope only exists after the component returns, and reading props.children
385
+ // anywhere, including destructuring it in the parameter list, would build the
386
+ // subtree first. Those set _deferChildren and receive the factory itself, which
387
+ // the render paths realize once the scope exists and which a boundary
388
+ // re-invokes to rebuild its subtree on a later attempt.
389
+ export function _installLazyChildren(Component, target, lazyChildren) {
390
+ if (Component._deferChildren) {
391
+ target.children = lazyChildren;
392
+ return null;
393
+ }
394
+ let realized;
395
+ let cached = false;
396
+ let inPass = true;
397
+ Object.defineProperty(target, 'children', {
398
+ get() {
399
+ if (!inPass) return lazyChildren();
400
+ if (!cached) {
401
+ cached = true;
402
+ realized = lazyChildren();
403
+ }
404
+ return realized;
405
+ },
406
+ enumerable: true,
407
+ configurable: true,
408
+ });
409
+ return () => { inPass = false; cached = false; realized = undefined; };
410
+ }
411
+
412
+ // --- _handleNavigationSignal(error) ---
413
+ // A thrown value may carry its own handler under
414
+ // Symbol.for('what.navigation.signal'). what-router's redirect() throws one, so
415
+ // a redirect from a component body runs the navigation instead of reaching an
416
+ // ErrorBoundary, which would render error UI for a value that is not an error.
417
+ // Returns true when the value was a signal and has been handled.
418
+ //
419
+ // Both component paths route through this (createComponent below and the
420
+ // hydration branch in render.js) so the two cannot drift.
421
+ const NAV_SIGNAL = Symbol.for('what.navigation.signal');
422
+
423
+ export function _handleNavigationSignal(error) {
424
+ if (error == null) return false;
425
+ const handler = error[NAV_SIGNAL];
426
+ if (typeof handler !== 'function') return false;
427
+ handler(error);
428
+ return true;
429
+ }
430
+
288
431
  function createComponent(vnode, parent, isSvg) {
289
432
  let { tag: Component, props, children } = vnode;
290
433
 
@@ -363,6 +506,9 @@ function createComponent(vnode, parent, isSvg) {
363
506
  } else {
364
507
  mergedProps = props ? Object.assign({}, props) : {};
365
508
  }
509
+ const lazyChildren = props && props._$lazyChildren;
510
+ const endChildrenPass = lazyChildren ? _installLazyChildren(Component, mergedProps, lazyChildren) : null;
511
+
366
512
  const propsSignal = signal(mergedProps);
367
513
  ctx._propsSignal = propsSignal;
368
514
 
@@ -387,7 +533,12 @@ function createComponent(vnode, parent, isSvg) {
387
533
  result = untrack(() => Component(reactiveProps));
388
534
  } catch (error) {
389
535
  componentStack.pop();
390
- if (!reportError(error, ctx)) {
536
+ // A thrown thenable is a suspension, not a failure: hand it to the nearest
537
+ // Suspense boundary, which swaps in its fallback and re-renders on resolve.
538
+ // A navigation signal is neither: it carries its own handler.
539
+ if (!_handleNavigationSignal(error)
540
+ && !(error && typeof error.then === 'function' && suspend(error, ctx))
541
+ && !reportError(error, ctx)) {
391
542
  console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
392
543
  throw error;
393
544
  }
@@ -396,8 +547,10 @@ function createComponent(vnode, parent, isSvg) {
396
547
  container.appendChild(endComment);
397
548
  return container;
398
549
  }
550
+ // The component has run; anything that reads props.children from here on is a
551
+ // later render pass and must build its own children.
552
+ if (endChildrenPass) endChildrenPass();
399
553
 
400
- componentStack.pop();
401
554
  ctx.mounted = true;
402
555
 
403
556
  // Run onMount callbacks after DOM is ready
@@ -411,17 +564,38 @@ function createComponent(vnode, parent, isSvg) {
411
564
  }
412
565
 
413
566
  // Build fragment: <!-- c:start --> [component output] <!-- c:end -->
567
+ // ctx stays on the stack while children are realized so that a child's
568
+ // parentCtx (and therefore useContext / error-boundary lookup) resolves to
569
+ // this component rather than to whatever rendered it.
414
570
  container.appendChild(startComment);
415
571
  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);
572
+ try {
573
+ for (const v of vnodes) {
574
+ const node = createDOM(v, container, isSvg);
575
+ if (node) container.appendChild(node);
576
+ }
577
+ } finally {
578
+ componentStack.pop();
419
579
  }
420
580
  container.appendChild(endComment);
421
581
 
422
582
  return container;
423
583
  }
424
584
 
585
+ // Walk up from ctx to the nearest Suspense boundary and notify it. Returns
586
+ // false when nothing in the chain can handle the suspension.
587
+ function suspend(promise, ctx) {
588
+ let c = ctx;
589
+ while (c) {
590
+ if (c._suspenseBoundary) {
591
+ c._suspenseBoundary.onSuspend(promise);
592
+ return true;
593
+ }
594
+ c = c._parentCtx;
595
+ }
596
+ return false;
597
+ }
598
+
425
599
  // Error boundary component handler
426
600
  function createErrorBoundary(vnode, parent) {
427
601
  const { errorState, handleError, fallback, reset } = vnode.props;
@@ -504,6 +678,7 @@ function createSuspenseBoundary(vnode, parent) {
504
678
  hooks: [], hookIndex: 0, effects: [], cleanups: [],
505
679
  mounted: false, disposed: false,
506
680
  _parentCtx: componentStack[componentStack.length - 1] || null,
681
+ _suspenseBoundary: boundary,
507
682
  _startComment: startComment,
508
683
  _endComment: endComment,
509
684
  };
@@ -514,10 +689,16 @@ function createSuspenseBoundary(vnode, parent) {
514
689
  container.appendChild(startComment);
515
690
  container.appendChild(endComment);
516
691
 
692
+ // A child suspending mid-render flips `loading` while this effect is still
693
+ // running, which can re-enter it. The generation counter lets the outer run
694
+ // detect that a newer run already replaced the content and bail out.
695
+ let generation = 0;
696
+
517
697
  const dispose = effect(() => {
518
698
  const isLoading = loading();
519
699
  const vnodes = isLoading ? [fallback] : children;
520
700
  const normalized = Array.isArray(vnodes) ? vnodes : [vnodes];
701
+ const gen = ++generation;
521
702
 
522
703
  componentStack.push(boundaryCtx);
523
704
 
@@ -530,20 +711,26 @@ function createSuspenseBoundary(vnode, parent) {
530
711
  }
531
712
  }
532
713
 
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);
714
+ try {
715
+ for (const v of normalized) {
716
+ const node = createDOM(v, parent);
717
+ if (gen !== generation) {
718
+ if (node) disposeTree(node);
719
+ break;
720
+ }
721
+ if (node) {
722
+ // Insert before endComment
723
+ if (endComment.parentNode) {
724
+ endComment.parentNode.insertBefore(node, endComment);
725
+ } else {
726
+ // Still in fragment before first mount
727
+ container.insertBefore(node, endComment);
728
+ }
542
729
  }
543
730
  }
731
+ } finally {
732
+ componentStack.pop();
544
733
  }
545
-
546
- componentStack.pop();
547
734
  });
548
735
 
549
736
  boundaryCtx.effects.push(dispose);
@@ -653,13 +840,14 @@ export function _setSelectValue(el, value) {
653
840
  // legacy diff-driven update path.
654
841
  // - render.js setProp — fine-grained-compiler output path. No event-handler
655
842
  // 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.
843
+ // compile time), but adds the innerHTML `{__html}` enforcement that the
844
+ // compiler relies on.
845
+ // Both share the `el._propEffects[key]` disposer convention and both gate
846
+ // attributes through _isUnsafeAttr() so URL sanitization cannot diverge. Do not
847
+ // merge without consolidating the event/listener model: they have different callers.
660
848
  function setProp(el, key, value, isSvg) {
661
849
  // Reactive function props — wrap in effect for fine-grained updates
662
- if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
850
+ if (typeof value === 'function' && !_isEventProp(key) && key !== 'ref') {
663
851
  if (!el._propEffects) el._propEffects = {};
664
852
  if (el._propEffects[key]) {
665
853
  try { el._propEffects[key](); } catch (e) { /* already disposed */ }
@@ -672,7 +860,8 @@ function setProp(el, key, value, isSvg) {
672
860
  }
673
861
 
674
862
  // Event handlers
675
- if (key.startsWith('on') && key.length > 2) {
863
+ if (_isEventProp(key)) {
864
+ if (typeof value !== 'function' && value != null) return;
676
865
  let eventName = key.slice(2);
677
866
  let useCapture = false;
678
867
  if (eventName.endsWith('Capture')) {
@@ -700,6 +889,14 @@ function setProp(el, key, value, isSvg) {
700
889
  return;
701
890
  }
702
891
 
892
+ // Reject dangerous URL protocols and srcdoc
893
+ if (_isUnsafeAttr(key, value)) {
894
+ if (typeof console !== 'undefined') {
895
+ console.warn(`[what] Blocked unsafe URL in "${key}" attribute:`, value);
896
+ }
897
+ return;
898
+ }
899
+
703
900
  // className / class
704
901
  if (key === 'className' || key === 'class') {
705
902
  if (isSvg) {
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/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.