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/dist/chunk-NCPX66TV.min.js +1 -0
- package/dist/chunk-RXISSKLI.min.js +11 -0
- package/dist/index.min.js +18 -15
- package/dist/render.min.js +1 -1
- package/dist/testing.min.js +1 -1
- package/index.d.ts +182 -11
- 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 +175 -82
- package/src/data.js +47 -4
- package/src/dom.js +269 -24
- package/src/errors.js +29 -0
- package/src/head.js +35 -5
- package/src/hooks.js +3 -0
- package/src/index.js +3 -0
- package/src/reactive.js +5 -5
- package/src/render.js +99 -35
- package/src/server-context.js +12 -0
- package/testing.d.ts +36 -1
- package/dist/chunk-5QCEMXNL.min.js +0 -1
- package/dist/chunk-H67HFVDV.min.js +0 -1
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
|
-
|
|
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
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
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
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
657
|
-
//
|
|
658
|
-
// Both share the `el._propEffects[key]` disposer convention
|
|
659
|
-
//
|
|
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
|
|
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 (
|
|
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-*
|
|
784
|
-
if (key.startsWith('data-')
|
|
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
|
|
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/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
|
|
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.
|