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/dist/chunk-JIZR6JX5.min.js +1 -0
- package/dist/chunk-PABR63P3.min.js +1 -0
- package/dist/index.min.js +19 -6
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +152 -11
- package/package.json +1 -1
- package/src/agent-context.js +1 -1
- package/src/components.js +47 -22
- package/src/dom.js +219 -22
- package/src/errors.js +29 -0
- package/src/head.js +35 -5
- package/src/hooks.js +3 -0
- package/src/reactive.js +5 -5
- package/src/render.js +73 -34
- package/dist/chunk-5QCEMXNL.min.js +0 -1
- package/dist/chunk-H67HFVDV.min.js +0 -1
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
|
-
|
|
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
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
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
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
657
|
-
//
|
|
658
|
-
// Both share the `el._propEffects[key]` disposer convention
|
|
659
|
-
//
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
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.
|