what-core 0.5.5 → 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/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 = html.trim();
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
@@ -136,7 +266,10 @@ function reconcileInsert(parent, value, current, marker) {
136
266
  for (let i = newNodes.length - 1; i >= 0; i--) {
137
267
  const node = newNodes[i];
138
268
  if (node.parentNode !== parent || node.nextSibling !== ref) {
139
- parent.insertBefore(node, ref);
269
+ // Guard against stale ref from nested reconciliation
270
+ if (ref && ref.parentNode !== parent) ref = null;
271
+ if (ref) parent.insertBefore(node, ref);
272
+ else parent.appendChild(node);
140
273
  }
141
274
  ref = node;
142
275
  }
@@ -187,11 +320,15 @@ function reconcileList(parent, endMarker, oldItems, newItems, mappedNodes, dispo
187
320
  const oldLen = oldItems.length;
188
321
 
189
322
  if (newLen === 0) {
190
- // Fast path: clear all
323
+ // Fast path: clear all — remove only this list's nodes, not all parent content
191
324
  if (oldLen > 0) {
192
- for (let i = 0; i < oldLen; i++) disposeFns[i]?.();
193
- parent.textContent = '';
194
- parent.appendChild(endMarker);
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
+ }
195
332
  mappedNodes.length = 0;
196
333
  disposeFns.length = 0;
197
334
  }
@@ -360,6 +497,8 @@ function _reconcileMiddle(parent, endMarker, oldItems, newItems, mappedNodes, di
360
497
  const mi = i - start;
361
498
  if (oldIndices[mi] === -1 || !inLIS[mi]) {
362
499
  // New item or moved item — insert
500
+ // Guard against stale nextSibling from nested reconciliation
501
+ if (nextSibling && nextSibling.parentNode !== parent) nextSibling = endMarker;
363
502
  parent.insertBefore(newMapped[i], nextSibling);
364
503
  }
365
504
  nextSibling = newMapped[i];
@@ -422,11 +561,15 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
422
561
  // --- Fast path: clear all ---
423
562
  if (newLen === 0) {
424
563
  if (oldLen > 0) {
425
- // Skip individual disposal: per-row effects only subscribe to their item signal,
426
- // which is also being discarded. Both become unreachable → GC collects them.
427
- // Bulk DOM removal: clear parent, re-add marker.
428
- parent.textContent = '';
429
- parent.appendChild(endMarker);
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
+ }
430
573
  mappedNodes.length = 0;
431
574
  disposeFns.length = 0;
432
575
  if (keyedState) keyedState.clear();
@@ -649,6 +792,8 @@ function reconcileKeyed(parent, endMarker, oldItems, newItems, mappedNodes, disp
649
792
  for (let i = newEnd; i >= start; i--) {
650
793
  const mi = i - start;
651
794
  if (oldIndices[mi] === -1 || !inLIS[mi]) {
795
+ // Guard against stale nextSibling from nested reconciliation
796
+ if (nextSibling && nextSibling.parentNode !== parent) nextSibling = endMarker;
652
797
  parent.insertBefore(newMapped[i], nextSibling);
653
798
  }
654
799
  nextSibling = newMapped[i];
@@ -703,6 +848,16 @@ export function spread(el, props) {
703
848
  }
704
849
 
705
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
+
706
861
  if (key === 'class' || key === 'className') {
707
862
  el.className = value || '';
708
863
  } else if (key === 'dangerouslySetInnerHTML') {
@@ -711,7 +866,13 @@ export function setProp(el, key, value) {
711
866
  if (value && typeof value === 'object' && '__html' in value) {
712
867
  el.innerHTML = value.__html ?? '';
713
868
  } else {
714
- el.innerHTML = value ?? '';
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
715
876
  }
716
877
  } else if (key === 'style') {
717
878
  if (typeof value === 'string') {
@@ -777,3 +938,280 @@ export function classList(el, classes) {
777
938
  }
778
939
  });
779
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
+ }