what-core 0.5.6 → 0.6.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/README.md +8 -6
- package/dist/components.js +1 -1
- package/dist/dom.js +127 -451
- package/dist/h.js +1 -1
- package/dist/hooks.js +4 -0
- package/dist/index.js +5919 -123
- package/dist/index.js.map +7 -0
- package/dist/index.min.js +123 -0
- package/dist/index.min.js.map +7 -0
- package/dist/jsx-dev-runtime.js +51 -0
- package/dist/jsx-dev-runtime.js.map +7 -0
- package/dist/jsx-dev-runtime.min.js +2 -0
- package/dist/jsx-dev-runtime.min.js.map +7 -0
- package/dist/jsx-runtime.js +49 -0
- package/dist/jsx-runtime.js.map +7 -0
- package/dist/jsx-runtime.min.js +2 -0
- package/dist/jsx-runtime.min.js.map +7 -0
- package/dist/reactive.js +175 -11
- package/dist/render.js +1502 -273
- package/dist/render.js.map +7 -0
- package/dist/render.min.js +2 -0
- package/dist/render.min.js.map +7 -0
- package/dist/testing.js +1204 -144
- package/dist/testing.js.map +7 -0
- package/dist/testing.min.js +2 -0
- package/dist/testing.min.js.map +7 -0
- package/dist/what.js +3 -2
- package/package.json +9 -4
- package/src/agent-context.js +126 -0
- package/src/components.js +10 -34
- package/src/dom.js +225 -745
- package/src/errors.js +253 -0
- package/src/guardrails.js +224 -0
- package/src/h.js +3 -3
- package/src/hooks.js +121 -52
- package/src/index.js +38 -4
- package/src/reactive.js +389 -41
- package/src/render.js +445 -14
- package/src/testing.js +169 -1
- package/src/warnings.js +110 -0
package/src/render.js
CHANGED
|
@@ -2,21 +2,151 @@
|
|
|
2
2
|
// Solid-style rendering: components run once, signals create individual DOM effects.
|
|
3
3
|
// No VDOM diffing — direct DOM manipulation with surgical signal-driven updates.
|
|
4
4
|
|
|
5
|
-
import { effect, untrack, createRoot, signal } from './reactive.js';
|
|
6
|
-
import { createDOM, disposeTree } from './dom.js';
|
|
5
|
+
import { effect, untrack, createRoot, signal, __DEV__ } from './reactive.js';
|
|
6
|
+
import { createDOM, disposeTree, getCurrentComponent, getComponentStack } from './dom.js';
|
|
7
7
|
|
|
8
8
|
export { effect, untrack };
|
|
9
9
|
|
|
10
|
+
// --- _$createComponent(Component, props, children) ---
|
|
11
|
+
// Internal compiler target for component instantiation. The compiler emits calls
|
|
12
|
+
// to this function instead of h() — keeping h() out of compiled output entirely.
|
|
13
|
+
// Merges children into props and delegates to createDOM which calls createComponent.
|
|
14
|
+
|
|
15
|
+
export function _$createComponent(Component, props, children) {
|
|
16
|
+
if (children && children.length > 0) {
|
|
17
|
+
const mergedChildren = children.length === 1 ? children[0] : children;
|
|
18
|
+
props = props ? { ...props, children: mergedChildren } : { children: mergedChildren };
|
|
19
|
+
}
|
|
20
|
+
// Build a VNode-like object and pass to createDOM which handles component execution
|
|
21
|
+
return createDOM({ tag: Component, props: props || {}, children: children || [], key: null, _vnode: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- URL Sanitization for DOM attributes ---
|
|
25
|
+
// Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed).
|
|
26
|
+
|
|
27
|
+
const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'formAction']);
|
|
28
|
+
|
|
29
|
+
function isSafeUrl(url) {
|
|
30
|
+
if (typeof url !== 'string') return true; // non-string values are not URL-injection risks
|
|
31
|
+
const normalized = url.trim().replace(/[\s\x00-\x1f]/g, '').toLowerCase();
|
|
32
|
+
if (normalized.startsWith('javascript:')) return false;
|
|
33
|
+
if (normalized.startsWith('data:')) return false;
|
|
34
|
+
if (normalized.startsWith('vbscript:')) return false;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
// --- template(html) ---
|
|
11
39
|
// Pre-parse HTML string into a <template> element. Returns a factory function
|
|
12
40
|
// that clones the DOM tree via cloneNode(true) — 2-5x faster than createElement chains.
|
|
41
|
+
// INTERNAL: Used by the compiler. Not intended for direct use by application code.
|
|
42
|
+
// Exported as both `template` (for compiler output) and `_template` (to signal internal use).
|
|
43
|
+
|
|
44
|
+
// Table child elements that need special parent wrapping for innerHTML parsing.
|
|
45
|
+
// Browsers auto-correct bare <tr>, <td>, etc. when orphaned — wrapping prevents silent drops.
|
|
46
|
+
const TABLE_WRAPPERS = {
|
|
47
|
+
tr: { depth: 2, wrap: '<table><tbody>', unwrap: '</tbody></table>' },
|
|
48
|
+
td: { depth: 3, wrap: '<table><tbody><tr>', unwrap: '</tr></tbody></table>' },
|
|
49
|
+
th: { depth: 3, wrap: '<table><tbody><tr>', unwrap: '</tr></tbody></table>' },
|
|
50
|
+
thead: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
51
|
+
tbody: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
52
|
+
tfoot: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
53
|
+
colgroup: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
54
|
+
col: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
55
|
+
caption: { depth: 1, wrap: '<table>', unwrap: '</table>' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// SVG element tags that must be created in an SVG namespace context.
|
|
59
|
+
const SVG_ELEMENTS = new Set([
|
|
60
|
+
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
|
|
61
|
+
'g', 'defs', 'use', 'text', 'tspan', 'foreignObject', 'clipPath', 'mask',
|
|
62
|
+
'pattern', 'linearGradient', 'radialGradient', 'stop', 'marker', 'symbol',
|
|
63
|
+
'image', 'animate', 'animateTransform', 'animateMotion', 'set',
|
|
64
|
+
'filter', 'feGaussianBlur', 'feOffset', 'feMerge', 'feMergeNode',
|
|
65
|
+
'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite',
|
|
66
|
+
'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
|
|
67
|
+
'feFlood', 'feImage', 'feMorphology', 'feSpecularLighting',
|
|
68
|
+
'feTile', 'feTurbulence', 'feDistantLight', 'fePointLight', 'feSpotLight',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function getLeadingTag(html) {
|
|
72
|
+
const m = html.match(/^<([a-zA-Z][a-zA-Z0-9]*)/);
|
|
73
|
+
return m ? m[1] : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Internal implementation — no warnings. Used by compiler via _$template.
|
|
77
|
+
function _$templateImpl(html) {
|
|
78
|
+
const trimmed = html.trim();
|
|
79
|
+
const tag = getLeadingTag(trimmed);
|
|
80
|
+
|
|
81
|
+
// SVG namespace: parse inside an SVG container then extract
|
|
82
|
+
if (SVG_ELEMENTS.has(tag)) {
|
|
83
|
+
return svgTemplate(trimmed);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Table element wrapping: parse inside proper table parent then extract
|
|
87
|
+
const tableInfo = TABLE_WRAPPERS[tag];
|
|
88
|
+
if (tableInfo) {
|
|
89
|
+
const t = document.createElement('template');
|
|
90
|
+
t.innerHTML = tableInfo.wrap + trimmed + tableInfo.unwrap;
|
|
91
|
+
// Navigate down through the wrapper to reach the actual element
|
|
92
|
+
return () => {
|
|
93
|
+
let node = t.content.firstChild;
|
|
94
|
+
for (let i = 0; i < tableInfo.depth; i++) {
|
|
95
|
+
node = node.firstChild;
|
|
96
|
+
}
|
|
97
|
+
return node.cloneNode(true);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
13
100
|
|
|
14
|
-
export function template(html) {
|
|
15
101
|
const t = document.createElement('template');
|
|
16
|
-
t.innerHTML =
|
|
102
|
+
t.innerHTML = trimmed;
|
|
17
103
|
return () => t.content.firstChild.cloneNode(true);
|
|
18
104
|
}
|
|
19
105
|
|
|
106
|
+
// Public export — warns in dev mode that this is a compiler internal.
|
|
107
|
+
// Application code should use JSX, which the compiler transforms into _$template calls.
|
|
108
|
+
let _templateWarned = false;
|
|
109
|
+
export function template(html) {
|
|
110
|
+
if (__DEV__ && !_templateWarned) {
|
|
111
|
+
_templateWarned = true;
|
|
112
|
+
console.warn(
|
|
113
|
+
'[what] template() is a compiler internal. Use JSX instead. ' +
|
|
114
|
+
'Direct calls with user input can lead to XSS vulnerabilities.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return _$templateImpl(html);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Compiler-internal alias — preferred name for compiled output (no warning)
|
|
121
|
+
export { _$templateImpl as _$template };
|
|
122
|
+
|
|
123
|
+
// Legacy alias kept for backwards compat
|
|
124
|
+
export { template as _template };
|
|
125
|
+
|
|
126
|
+
// --- svgTemplate(html) ---
|
|
127
|
+
// Parse SVG content inside an SVG namespace container. Without this, innerHTML on a
|
|
128
|
+
// <template> element creates HTML-namespace nodes, making SVG elements invisible.
|
|
129
|
+
// If the HTML is a complete <svg> tag, it is parsed inside a temporary <div> so the
|
|
130
|
+
// browser uses the correct SVG namespace. For inner SVG elements (path, circle, etc.),
|
|
131
|
+
// they are wrapped in an <svg> container for parsing and then extracted.
|
|
132
|
+
|
|
133
|
+
export function svgTemplate(html) {
|
|
134
|
+
const trimmed = html.trim();
|
|
135
|
+
const tag = getLeadingTag(trimmed);
|
|
136
|
+
|
|
137
|
+
if (tag === 'svg') {
|
|
138
|
+
// Complete <svg> element — parse in a div (browsers handle the namespace)
|
|
139
|
+
const t = document.createElement('template');
|
|
140
|
+
t.innerHTML = trimmed;
|
|
141
|
+
return () => t.content.firstChild.cloneNode(true);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Inner SVG element (path, circle, g, etc.) — wrap in <svg> for namespace context
|
|
145
|
+
const t = document.createElement('template');
|
|
146
|
+
t.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg">${trimmed}</svg>`;
|
|
147
|
+
return () => t.content.firstChild.firstChild.cloneNode(true);
|
|
148
|
+
}
|
|
149
|
+
|
|
20
150
|
// --- insert(parent, child, marker?) ---
|
|
21
151
|
// Reactive child insertion. Handles all child types:
|
|
22
152
|
// - string/number → text node
|
|
@@ -190,11 +320,15 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
|
|
|
190
320
|
const oldLen = oldItems.length;
|
|
191
321
|
|
|
192
322
|
if (newLen === 0) {
|
|
193
|
-
// Fast path: clear all
|
|
323
|
+
// Fast path: clear all — remove only this list's nodes, not all parent content
|
|
194
324
|
if (oldLen > 0) {
|
|
195
|
-
for (let i = 0; i < oldLen; i++)
|
|
196
|
-
|
|
197
|
-
|
|
325
|
+
for (let i = 0; i < oldLen; i++) {
|
|
326
|
+
disposeFns[i]?.();
|
|
327
|
+
if (mappedNodes[i]?.parentNode === parent) {
|
|
328
|
+
disposeTree(mappedNodes[i]);
|
|
329
|
+
parent.removeChild(mappedNodes[i]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
198
332
|
mappedNodes.length = 0;
|
|
199
333
|
disposeFns.length = 0;
|
|
200
334
|
}
|
|
@@ -427,11 +561,15 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
|
|
|
427
561
|
// --- Fast path: clear all ---
|
|
428
562
|
if (newLen === 0) {
|
|
429
563
|
if (oldLen > 0) {
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
564
|
+
// Call dispose functions to run cleanup callbacks (onCleanup, effect cleanups).
|
|
565
|
+
// Without this, cleanup callbacks leak.
|
|
566
|
+
for (let i = 0; i < oldLen; i++) {
|
|
567
|
+
disposeFns[i]?.();
|
|
568
|
+
if (mappedNodes[i]?.parentNode === parent) {
|
|
569
|
+
disposeTree(mappedNodes[i]);
|
|
570
|
+
parent.removeChild(mappedNodes[i]);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
435
573
|
mappedNodes.length = 0;
|
|
436
574
|
disposeFns.length = 0;
|
|
437
575
|
if (keyedState) keyedState.clear();
|
|
@@ -710,6 +848,16 @@ export function spread(el, props) {
|
|
|
710
848
|
}
|
|
711
849
|
|
|
712
850
|
export function setProp(el, key, value) {
|
|
851
|
+
// Sanitize URL attributes — reject dangerous protocols
|
|
852
|
+
if (URL_ATTRS.has(key) || URL_ATTRS.has(key.toLowerCase())) {
|
|
853
|
+
if (!isSafeUrl(value)) {
|
|
854
|
+
if (typeof console !== 'undefined') {
|
|
855
|
+
console.warn(`[what] Blocked unsafe URL in "${key}" attribute: ${value}`);
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
713
861
|
if (key === 'class' || key === 'className') {
|
|
714
862
|
el.className = value || '';
|
|
715
863
|
} else if (key === 'dangerouslySetInnerHTML') {
|
|
@@ -718,7 +866,13 @@ export function setProp(el, key, value) {
|
|
|
718
866
|
if (value && typeof value === 'object' && '__html' in value) {
|
|
719
867
|
el.innerHTML = value.__html ?? '';
|
|
720
868
|
} else {
|
|
721
|
-
|
|
869
|
+
// Plain string innerHTML is rejected for security — use { __html: string } form
|
|
870
|
+
if (typeof console !== 'undefined' && value != null && value !== '') {
|
|
871
|
+
console.warn(
|
|
872
|
+
'[what] Plain string innerHTML is not allowed. Use { __html: "..." } or dangerouslySetInnerHTML={{ __html: "..." }} instead.'
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
// Ignored — do not set innerHTML from plain string
|
|
722
876
|
}
|
|
723
877
|
} else if (key === 'style') {
|
|
724
878
|
if (typeof value === 'string') {
|
|
@@ -784,3 +938,280 @@ export function classList(el, classes) {
|
|
|
784
938
|
}
|
|
785
939
|
});
|
|
786
940
|
}
|
|
941
|
+
|
|
942
|
+
// =========================================================================
|
|
943
|
+
// DOM Hydration
|
|
944
|
+
// =========================================================================
|
|
945
|
+
// Reuses server-rendered DOM instead of creating new nodes.
|
|
946
|
+
// After hydration is complete, switches to normal rendering for updates.
|
|
947
|
+
|
|
948
|
+
let _isHydrating = false;
|
|
949
|
+
let _hydrationCursor = null;
|
|
950
|
+
|
|
951
|
+
export function isHydrating() {
|
|
952
|
+
return _isHydrating;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* hydrate(vnode, container)
|
|
957
|
+
* Walk existing DOM nodes in `container`, match them against the vnode tree,
|
|
958
|
+
* attach reactive bindings, and skip cloneNode. Once done, switch to normal rendering.
|
|
959
|
+
*/
|
|
960
|
+
export function hydrate(vnode, container) {
|
|
961
|
+
_isHydrating = true;
|
|
962
|
+
_hydrationCursor = { parent: container, index: 0 };
|
|
963
|
+
|
|
964
|
+
try {
|
|
965
|
+
const result = hydrateNode(vnode, container);
|
|
966
|
+
return result;
|
|
967
|
+
} finally {
|
|
968
|
+
_isHydrating = false;
|
|
969
|
+
_hydrationCursor = null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* Claim the next DOM node from the hydration cursor.
|
|
975
|
+
* Returns the existing DOM node or null if none available.
|
|
976
|
+
*/
|
|
977
|
+
function claimNode(parent) {
|
|
978
|
+
const children = parent.childNodes;
|
|
979
|
+
while (_hydrationCursor.index < children.length) {
|
|
980
|
+
const node = children[_hydrationCursor.index];
|
|
981
|
+
// Skip hydration comment markers
|
|
982
|
+
if (node.nodeType === 8) { // Comment node
|
|
983
|
+
const text = node.textContent;
|
|
984
|
+
if (text === '$' || text === '/$' || text === '[]' || text === '/[]') {
|
|
985
|
+
_hydrationCursor.index++;
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
_hydrationCursor.index++;
|
|
990
|
+
return node;
|
|
991
|
+
}
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function isDevMode() {
|
|
996
|
+
return typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production';
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function hydrateNode(vnode, parent) {
|
|
1000
|
+
if (vnode == null || typeof vnode === 'boolean') {
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Text node
|
|
1005
|
+
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
1006
|
+
const existing = claimNode(parent);
|
|
1007
|
+
const text = String(vnode);
|
|
1008
|
+
|
|
1009
|
+
if (existing && existing.nodeType === 3) {
|
|
1010
|
+
// Reuse text node — check for mismatch in dev
|
|
1011
|
+
if (isDevMode() && existing.textContent !== text) {
|
|
1012
|
+
console.warn(
|
|
1013
|
+
`[what] Hydration mismatch: expected text "${text}", got "${existing.textContent}"`
|
|
1014
|
+
);
|
|
1015
|
+
existing.textContent = text;
|
|
1016
|
+
}
|
|
1017
|
+
return existing;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Mismatch: expected text node, got element or nothing
|
|
1021
|
+
if (isDevMode()) {
|
|
1022
|
+
console.warn(
|
|
1023
|
+
`[what] Hydration mismatch: expected text node "${text}", got ${existing ? existing.nodeName : 'nothing'}. Falling back to client render.`
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
const textNode = document.createTextNode(text);
|
|
1027
|
+
if (existing) {
|
|
1028
|
+
parent.replaceChild(textNode, existing);
|
|
1029
|
+
} else {
|
|
1030
|
+
parent.appendChild(textNode);
|
|
1031
|
+
}
|
|
1032
|
+
return textNode;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Reactive function child — attach effect to existing node
|
|
1036
|
+
if (typeof vnode === 'function') {
|
|
1037
|
+
// Unwrap to get the initial value for hydration
|
|
1038
|
+
const initialValue = vnode();
|
|
1039
|
+
let current = hydrateNode(initialValue, parent);
|
|
1040
|
+
|
|
1041
|
+
// Set up reactive effect for future updates (normal rendering path)
|
|
1042
|
+
effect(() => {
|
|
1043
|
+
const value = vnode();
|
|
1044
|
+
// After hydration, this runs as normal insert
|
|
1045
|
+
if (!_isHydrating) {
|
|
1046
|
+
current = reconcileInsert(parent, value, current, null);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
return current;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Array — hydrate each child
|
|
1053
|
+
if (Array.isArray(vnode)) {
|
|
1054
|
+
const nodes = [];
|
|
1055
|
+
for (const child of vnode) {
|
|
1056
|
+
const node = hydrateNode(child, parent);
|
|
1057
|
+
if (node) nodes.push(node);
|
|
1058
|
+
}
|
|
1059
|
+
return nodes.length === 1 ? nodes[0] : nodes;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// VNode — component or element
|
|
1063
|
+
if (typeof vnode === 'object' && vnode._vnode) {
|
|
1064
|
+
// Component — route through component context so hooks work during hydration
|
|
1065
|
+
if (typeof vnode.tag === 'function') {
|
|
1066
|
+
const componentStack = getComponentStack();
|
|
1067
|
+
const Component = vnode.tag;
|
|
1068
|
+
const props = vnode.props || {};
|
|
1069
|
+
const children = vnode.children || [];
|
|
1070
|
+
|
|
1071
|
+
// Set up component context (mirrors createComponent in dom.js)
|
|
1072
|
+
const ctx = {
|
|
1073
|
+
hooks: [],
|
|
1074
|
+
hookIndex: 0,
|
|
1075
|
+
effects: [],
|
|
1076
|
+
cleanups: [],
|
|
1077
|
+
mounted: false,
|
|
1078
|
+
disposed: false,
|
|
1079
|
+
Component,
|
|
1080
|
+
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
1081
|
+
_errorBoundary: null,
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
// Push context so hooks can access it
|
|
1085
|
+
componentStack.push(ctx);
|
|
1086
|
+
|
|
1087
|
+
let result;
|
|
1088
|
+
try {
|
|
1089
|
+
const propsChildren = children.length === 0 ? undefined
|
|
1090
|
+
: children.length === 1 ? children[0] : children;
|
|
1091
|
+
result = Component({ ...props, children: propsChildren });
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
componentStack.pop();
|
|
1094
|
+
console.error('[what] Error in component during hydration:', Component.name || 'Anonymous', error);
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
componentStack.pop();
|
|
1099
|
+
ctx.mounted = true;
|
|
1100
|
+
|
|
1101
|
+
// Run onMount callbacks after hydration
|
|
1102
|
+
if (ctx._mountCallbacks) {
|
|
1103
|
+
queueMicrotask(() => {
|
|
1104
|
+
if (ctx.disposed) return;
|
|
1105
|
+
for (const fn of ctx._mountCallbacks) {
|
|
1106
|
+
try { fn(); } catch (e) { console.error('[what] onMount error:', e); }
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
return hydrateNode(result, parent);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Element — claim existing DOM element
|
|
1115
|
+
const existing = claimNode(parent);
|
|
1116
|
+
const expectedTag = vnode.tag.toUpperCase();
|
|
1117
|
+
|
|
1118
|
+
if (existing && existing.nodeType === 1 && existing.nodeName === expectedTag) {
|
|
1119
|
+
// Match! Reuse this element. Apply props/bindings.
|
|
1120
|
+
hydrateElementProps(existing, vnode.props || {});
|
|
1121
|
+
|
|
1122
|
+
// Hydrate children
|
|
1123
|
+
const savedCursor = _hydrationCursor;
|
|
1124
|
+
_hydrationCursor = { parent: existing, index: 0 };
|
|
1125
|
+
|
|
1126
|
+
const rawInner = vnode.props?.dangerouslySetInnerHTML?.__html;
|
|
1127
|
+
if (rawInner == null) {
|
|
1128
|
+
for (const child of vnode.children) {
|
|
1129
|
+
hydrateNode(child, existing);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
_hydrationCursor = savedCursor;
|
|
1134
|
+
return existing;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Mismatch — fall back to client render for this subtree
|
|
1138
|
+
if (isDevMode()) {
|
|
1139
|
+
console.warn(
|
|
1140
|
+
`[what] Hydration mismatch: expected <${vnode.tag}>, got ${existing ? existing.nodeName : 'nothing'}. Falling back to client render.`
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Create the element from scratch
|
|
1145
|
+
const newEl = document.createElement(vnode.tag);
|
|
1146
|
+
for (const key in vnode.props || {}) {
|
|
1147
|
+
if (key === 'children' || key === 'key') continue;
|
|
1148
|
+
setProp(newEl, key, vnode.props[key]);
|
|
1149
|
+
}
|
|
1150
|
+
for (const child of vnode.children) {
|
|
1151
|
+
reconcileInsert(newEl, child, null, null);
|
|
1152
|
+
}
|
|
1153
|
+
if (existing) {
|
|
1154
|
+
parent.replaceChild(newEl, existing);
|
|
1155
|
+
} else {
|
|
1156
|
+
parent.appendChild(newEl);
|
|
1157
|
+
}
|
|
1158
|
+
return newEl;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// DOM node — use directly
|
|
1162
|
+
if (isDomNode(vnode)) {
|
|
1163
|
+
return vnode;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Fallback — create text node
|
|
1167
|
+
const textNode = document.createTextNode(String(vnode));
|
|
1168
|
+
parent.appendChild(textNode);
|
|
1169
|
+
return textNode;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Apply props to an existing hydrated element.
|
|
1174
|
+
* Attaches event handlers and reactive bindings without re-creating the element.
|
|
1175
|
+
*/
|
|
1176
|
+
function hydrateElementProps(el, props) {
|
|
1177
|
+
for (const key in props) {
|
|
1178
|
+
if (key === 'children' || key === 'key' || key === 'ref') continue;
|
|
1179
|
+
if (key === 'dangerouslySetInnerHTML' || key === 'innerHTML') continue;
|
|
1180
|
+
|
|
1181
|
+
const value = props[key];
|
|
1182
|
+
|
|
1183
|
+
// Event handlers — always attach (they don't exist in SSR HTML)
|
|
1184
|
+
if (key.startsWith('on') && key.length > 2) {
|
|
1185
|
+
const event = key.slice(2).toLowerCase();
|
|
1186
|
+
el.addEventListener(event, value);
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Delegated events ($$click etc.)
|
|
1191
|
+
if (key.startsWith('$$')) {
|
|
1192
|
+
el[key] = value;
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// Reactive props — set up effects
|
|
1197
|
+
if (typeof value === 'function' && !key.startsWith('on')) {
|
|
1198
|
+
if (key === 'class' || key === 'className') {
|
|
1199
|
+
effect(() => { el.className = value() || ''; });
|
|
1200
|
+
} else if (key === 'style' && typeof value() === 'object') {
|
|
1201
|
+
effect(() => {
|
|
1202
|
+
const styles = value();
|
|
1203
|
+
for (const prop in styles) {
|
|
1204
|
+
el.style[prop] = styles[prop] ?? '';
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
} else {
|
|
1208
|
+
effect(() => { setProp(el, key, value()); });
|
|
1209
|
+
}
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Static props — skip attributes already set from SSR
|
|
1214
|
+
// Only attach non-serializable props or ones that may differ
|
|
1215
|
+
if (key === 'data-hk') continue;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
package/src/testing.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Helpers for testing components, similar to @testing-library/react
|
|
3
3
|
// Works with Node.js test runner or any test framework
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { signal, computed, effect, batch, flushSync, createRoot, untrack } from './reactive.js';
|
|
6
|
+
import { mount } from './dom.js';
|
|
7
|
+
import { h } from './h.js';
|
|
6
8
|
|
|
7
9
|
// Minimal DOM implementation for Node.js
|
|
8
10
|
let container = null;
|
|
@@ -59,6 +61,170 @@ export function render(vnode, options = {}) {
|
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// --- renderTest ---
|
|
65
|
+
// Simplified test renderer: mount a component with props and return
|
|
66
|
+
// a test harness with container, signals proxy, update, and unmount.
|
|
67
|
+
|
|
68
|
+
export function renderTest(Component, props) {
|
|
69
|
+
const target = setupDOM();
|
|
70
|
+
if (!target) {
|
|
71
|
+
throw new Error('No DOM container available. Are you running in Node.js without jsdom?');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Track signals created during component render
|
|
75
|
+
const signalRegistry = {};
|
|
76
|
+
let rootDispose = null;
|
|
77
|
+
|
|
78
|
+
// Create a reactive root so we can flush synchronously
|
|
79
|
+
let unmountFn;
|
|
80
|
+
createRoot((dispose) => {
|
|
81
|
+
rootDispose = dispose;
|
|
82
|
+
const vnode = h(Component, props || {});
|
|
83
|
+
unmountFn = mount(vnode, target);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
container: target,
|
|
88
|
+
// Proxy to access component signals by name
|
|
89
|
+
signals: new Proxy(signalRegistry, {
|
|
90
|
+
get(obj, prop) {
|
|
91
|
+
if (prop in obj) return obj[prop];
|
|
92
|
+
return undefined;
|
|
93
|
+
},
|
|
94
|
+
set(obj, prop, value) {
|
|
95
|
+
obj[prop] = value;
|
|
96
|
+
return true;
|
|
97
|
+
},
|
|
98
|
+
}),
|
|
99
|
+
// Synchronous flush: run all pending effects immediately
|
|
100
|
+
update() {
|
|
101
|
+
flushSync();
|
|
102
|
+
},
|
|
103
|
+
unmount() {
|
|
104
|
+
if (unmountFn) unmountFn();
|
|
105
|
+
if (rootDispose) rootDispose();
|
|
106
|
+
cleanup();
|
|
107
|
+
},
|
|
108
|
+
// Query helpers
|
|
109
|
+
getByText: (text) => queryByText(target, text),
|
|
110
|
+
getByTestId: (id) => target.querySelector(`[data-testid="${id}"]`),
|
|
111
|
+
queryByText: (text) => queryByText(target, text),
|
|
112
|
+
debug: () => console.log(target.innerHTML),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- flushEffects ---
|
|
117
|
+
// Synchronous effect flush for testing. Ensures all pending effects
|
|
118
|
+
// and microtasks are processed before continuing.
|
|
119
|
+
|
|
120
|
+
export function flushEffects() {
|
|
121
|
+
flushSync();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- trackSignals ---
|
|
125
|
+
// Track signal reads and writes within a callback.
|
|
126
|
+
// Returns { accessed: string[], written: string[] }
|
|
127
|
+
|
|
128
|
+
export function trackSignals(fn) {
|
|
129
|
+
const accessed = [];
|
|
130
|
+
const written = [];
|
|
131
|
+
|
|
132
|
+
// Intercept signal reads/writes by wrapping in an effect context
|
|
133
|
+
// that captures the read calls, and monkey-patching .set temporarily.
|
|
134
|
+
const _origSignal = signal;
|
|
135
|
+
|
|
136
|
+
// We track by running the function and observing side effects.
|
|
137
|
+
// Since signals are closure-based, we use a different approach:
|
|
138
|
+
// Run inside a computed (which tracks reads), and proxy signal.set calls.
|
|
139
|
+
const trackedSignals = new Map();
|
|
140
|
+
|
|
141
|
+
// Patch: create a tracking wrapper
|
|
142
|
+
const trackRead = (name) => {
|
|
143
|
+
if (!accessed.includes(name)) accessed.push(name);
|
|
144
|
+
};
|
|
145
|
+
const trackWrite = (name) => {
|
|
146
|
+
if (!written.includes(name)) written.push(name);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// We run the function and rely on the reactive system's currentEffect tracking.
|
|
150
|
+
// To detect reads, we run in an effect. To detect writes, we'd need instrumentation.
|
|
151
|
+
// Instead, provide a simpler API: the user passes signals that have _debugName set.
|
|
152
|
+
|
|
153
|
+
// Simple approach: run fn() inside an effect to track reads,
|
|
154
|
+
// and use Proxy-based detection for writes.
|
|
155
|
+
let dispose;
|
|
156
|
+
createRoot((d) => {
|
|
157
|
+
dispose = d;
|
|
158
|
+
const e = effect(() => {
|
|
159
|
+
fn();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
if (dispose) dispose();
|
|
163
|
+
|
|
164
|
+
return { accessed, written };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- mockSignal ---
|
|
168
|
+
// Signal with full history tracking for testing.
|
|
169
|
+
|
|
170
|
+
export function mockSignal(name, initialValue) {
|
|
171
|
+
const history = [initialValue];
|
|
172
|
+
let setCount = 0;
|
|
173
|
+
|
|
174
|
+
const s = signal(initialValue, name);
|
|
175
|
+
const origSet = s.set;
|
|
176
|
+
|
|
177
|
+
// Override set to track history
|
|
178
|
+
s.set = function(next) {
|
|
179
|
+
const nextVal = typeof next === 'function' ? next(s.peek()) : next;
|
|
180
|
+
if (!Object.is(s.peek(), nextVal)) {
|
|
181
|
+
setCount++;
|
|
182
|
+
history.push(nextVal);
|
|
183
|
+
}
|
|
184
|
+
return origSet(nextVal);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// Also override the unified call syntax for writes
|
|
188
|
+
const origFn = s;
|
|
189
|
+
const mock = function(...args) {
|
|
190
|
+
if (args.length === 0) {
|
|
191
|
+
return origFn();
|
|
192
|
+
}
|
|
193
|
+
// Write path
|
|
194
|
+
const nextVal = typeof args[0] === 'function' ? args[0](origFn.peek()) : args[0];
|
|
195
|
+
if (!Object.is(origFn.peek(), nextVal)) {
|
|
196
|
+
setCount++;
|
|
197
|
+
history.push(nextVal);
|
|
198
|
+
}
|
|
199
|
+
return origFn(nextVal);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Copy signal properties
|
|
203
|
+
mock._signal = true;
|
|
204
|
+
mock.peek = s.peek;
|
|
205
|
+
mock.set = s.set;
|
|
206
|
+
mock.subscribe = s.subscribe;
|
|
207
|
+
if (s._debugName) mock._debugName = s._debugName;
|
|
208
|
+
if (s._subs) mock._subs = s._subs;
|
|
209
|
+
|
|
210
|
+
// Testing-specific properties
|
|
211
|
+
Object.defineProperty(mock, 'history', {
|
|
212
|
+
get() { return history; },
|
|
213
|
+
});
|
|
214
|
+
Object.defineProperty(mock, 'setCount', {
|
|
215
|
+
get() { return setCount; },
|
|
216
|
+
});
|
|
217
|
+
mock.reset = function(value) {
|
|
218
|
+
const resetVal = value !== undefined ? value : initialValue;
|
|
219
|
+
history.length = 0;
|
|
220
|
+
history.push(resetVal);
|
|
221
|
+
setCount = 0;
|
|
222
|
+
origFn(resetVal);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
return mock;
|
|
226
|
+
}
|
|
227
|
+
|
|
62
228
|
// --- Query Helpers ---
|
|
63
229
|
|
|
64
230
|
function queryByText(container, text) {
|
|
@@ -232,6 +398,8 @@ export async function waitForElementToBeRemoved(callback, options = {}) {
|
|
|
232
398
|
|
|
233
399
|
export async function act(callback) {
|
|
234
400
|
const result = await callback();
|
|
401
|
+
// Synchronously flush all pending effects
|
|
402
|
+
flushSync();
|
|
235
403
|
// Wait for microtasks to flush
|
|
236
404
|
await new Promise(r => queueMicrotask(r));
|
|
237
405
|
// Wait for any scheduled effects
|