what-core 0.5.6 → 0.6.1
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 +5983 -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 +1549 -272
- 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 +1257 -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 +269 -749
- 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 +450 -64
- package/src/render.js +462 -14
- package/src/testing.js +169 -1
- package/src/warnings.js +110 -0
package/src/dom.js
CHANGED
|
@@ -1,52 +1,11 @@
|
|
|
1
|
-
// What Framework - DOM
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// No virtual DOM tree kept in memory — we diff against the live DOM.
|
|
1
|
+
// What Framework - Fine-Grained DOM Runtime
|
|
2
|
+
// Components run ONCE. Signals create individual DOM effects.
|
|
3
|
+
// No VDOM reconciler, no diffing — direct DOM manipulation driven by signals.
|
|
5
4
|
|
|
6
5
|
import { effect, batch, untrack, signal, __DEV__, __devtools } from './reactive.js';
|
|
7
6
|
import { reportError, _injectGetCurrentComponent, shallowEqual } from './components.js';
|
|
8
7
|
import { _setComponentRef } from './helpers.js';
|
|
9
8
|
|
|
10
|
-
// Register <what-c> custom element to prevent flash of unstyled content
|
|
11
|
-
// Note: style is set in connectedCallback (not constructor) to comply with custom element spec
|
|
12
|
-
if (typeof customElements !== 'undefined' && !customElements.get('what-c')) {
|
|
13
|
-
customElements.define('what-c', class extends HTMLElement {
|
|
14
|
-
connectedCallback() {
|
|
15
|
-
this.style.display = 'contents';
|
|
16
|
-
}
|
|
17
|
-
// display:contents elements don't generate a layout box — getBoundingClientRect()
|
|
18
|
-
// returns zeros, offsetWidth/Height return 0. React libraries (react-draggable,
|
|
19
|
-
// react-colorful, etc.) traverse parentNode and call getBoundingClientRect() on
|
|
20
|
-
// what they expect to be a layout container. Since <what-c> is layout-invisible,
|
|
21
|
-
// delegate to the nearest ancestor that has a real box.
|
|
22
|
-
_layoutParent() {
|
|
23
|
-
let el = this.parentElement;
|
|
24
|
-
while (el && el.tagName === 'WHAT-C') el = el.parentElement;
|
|
25
|
-
return el;
|
|
26
|
-
}
|
|
27
|
-
getBoundingClientRect() {
|
|
28
|
-
const p = this._layoutParent();
|
|
29
|
-
return p ? p.getBoundingClientRect() : super.getBoundingClientRect();
|
|
30
|
-
}
|
|
31
|
-
get offsetWidth() {
|
|
32
|
-
const p = this._layoutParent();
|
|
33
|
-
return p ? p.offsetWidth : 0;
|
|
34
|
-
}
|
|
35
|
-
get offsetHeight() {
|
|
36
|
-
const p = this._layoutParent();
|
|
37
|
-
return p ? p.offsetHeight : 0;
|
|
38
|
-
}
|
|
39
|
-
get clientWidth() {
|
|
40
|
-
const p = this._layoutParent();
|
|
41
|
-
return p ? p.clientWidth : 0;
|
|
42
|
-
}
|
|
43
|
-
get clientHeight() {
|
|
44
|
-
const p = this._layoutParent();
|
|
45
|
-
return p ? p.clientHeight : 0;
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
9
|
// SVG elements that need namespace
|
|
51
10
|
const SVG_ELEMENTS = new Set([
|
|
52
11
|
'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
|
|
@@ -63,6 +22,9 @@ const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
63
22
|
// Track all mounted component contexts for disposal
|
|
64
23
|
const mountedComponents = new Set();
|
|
65
24
|
|
|
25
|
+
// WeakMap: comment node → component context (for comment-node boundaries)
|
|
26
|
+
const _commentCtxMap = new WeakMap();
|
|
27
|
+
|
|
66
28
|
function isDomNode(value) {
|
|
67
29
|
if (!value || typeof value !== 'object') return false;
|
|
68
30
|
if (typeof Node !== 'undefined' && value instanceof Node) return true;
|
|
@@ -78,24 +40,34 @@ function disposeComponent(ctx) {
|
|
|
78
40
|
if (ctx.disposed) return;
|
|
79
41
|
ctx.disposed = true;
|
|
80
42
|
|
|
81
|
-
// Run
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
try { hook.cleanup(); } catch (e) { console.error('[what] cleanup error:', e); }
|
|
43
|
+
// Run cleanup callbacks
|
|
44
|
+
if (ctx.cleanups) {
|
|
45
|
+
for (const cleanup of ctx.cleanups) {
|
|
46
|
+
try { cleanup(); } catch (e) { console.error('[what] cleanup error:', e); }
|
|
86
47
|
}
|
|
87
48
|
}
|
|
88
49
|
|
|
89
|
-
// Run
|
|
90
|
-
if (ctx.
|
|
91
|
-
for (
|
|
92
|
-
try {
|
|
50
|
+
// Run effect disposals
|
|
51
|
+
if (ctx.effects) {
|
|
52
|
+
for (const dispose of ctx.effects) {
|
|
53
|
+
try { dispose(); } catch (e) { /* already disposed */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Run hook cleanups (useEffect return values)
|
|
58
|
+
if (ctx.hooks) {
|
|
59
|
+
for (const hook of ctx.hooks) {
|
|
60
|
+
if (hook && typeof hook.cleanup === 'function') {
|
|
61
|
+
try { hook.cleanup(); } catch (e) { console.error('[what] hook cleanup error:', e); }
|
|
62
|
+
}
|
|
93
63
|
}
|
|
94
64
|
}
|
|
95
65
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
66
|
+
// Run onCleanup callbacks
|
|
67
|
+
if (ctx._cleanupCallbacks) {
|
|
68
|
+
for (const fn of ctx._cleanupCallbacks) {
|
|
69
|
+
try { fn(); } catch (e) { console.error('[what] onCleanup error:', e); }
|
|
70
|
+
}
|
|
99
71
|
}
|
|
100
72
|
|
|
101
73
|
if (__DEV__ && __devtools?.onComponentUnmount) __devtools.onComponentUnmount(ctx);
|
|
@@ -108,6 +80,11 @@ export function disposeTree(node) {
|
|
|
108
80
|
if (node._componentCtx) {
|
|
109
81
|
disposeComponent(node._componentCtx);
|
|
110
82
|
}
|
|
83
|
+
// Check comment node WeakMap for component context
|
|
84
|
+
const commentCtx = _commentCtxMap.get(node);
|
|
85
|
+
if (commentCtx) {
|
|
86
|
+
disposeComponent(commentCtx);
|
|
87
|
+
}
|
|
111
88
|
// Dispose reactive function child effects ({() => ...} wrappers)
|
|
112
89
|
if (node._dispose) {
|
|
113
90
|
try { node._dispose(); } catch (e) { /* already disposed */ }
|
|
@@ -153,37 +130,66 @@ export function createDOM(vnode, parent, isSvg) {
|
|
|
153
130
|
return document.createTextNode(String(vnode));
|
|
154
131
|
}
|
|
155
132
|
|
|
156
|
-
// DOM node passthrough (
|
|
133
|
+
// DOM node passthrough (fine-grained components return real nodes)
|
|
157
134
|
if (isDomNode(vnode)) {
|
|
158
135
|
return vnode;
|
|
159
136
|
}
|
|
160
137
|
|
|
161
|
-
// Reactive function child —
|
|
162
|
-
//
|
|
138
|
+
// Reactive function child — use comment markers (no wrapper element)
|
|
139
|
+
// to avoid polluting the DOM and breaking CSS selectors like :first-child.
|
|
163
140
|
if (typeof vnode === 'function') {
|
|
164
|
-
const
|
|
165
|
-
|
|
141
|
+
const startMarker = document.createComment('fn');
|
|
142
|
+
const endMarker = document.createComment('/fn');
|
|
143
|
+
let currentNodes = [];
|
|
144
|
+
// We need a parent to insert between markers. The caller (createElementFromVNode
|
|
145
|
+
// or createComponent) will appendChild both markers and the content. We return
|
|
146
|
+
// a document fragment containing start marker, then the effect will manage nodes
|
|
147
|
+
// between start and end markers once they're in the real DOM.
|
|
148
|
+
const frag = document.createDocumentFragment();
|
|
149
|
+
frag.appendChild(startMarker);
|
|
150
|
+
frag.appendChild(endMarker);
|
|
151
|
+
|
|
166
152
|
const dispose = effect(() => {
|
|
167
153
|
const val = vnode();
|
|
168
|
-
// Normalize: null/false/true → empty, primitives and vnodes → array
|
|
169
154
|
const vnodes = (val == null || val === false || val === true)
|
|
170
155
|
? []
|
|
171
156
|
: Array.isArray(val) ? val : [val];
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
157
|
+
|
|
158
|
+
const realParent = endMarker.parentNode;
|
|
159
|
+
if (!realParent) return; // not mounted yet — first run handled below
|
|
160
|
+
|
|
161
|
+
// Remove old nodes between markers
|
|
162
|
+
for (const old of currentNodes) {
|
|
163
|
+
disposeTree(old);
|
|
164
|
+
if (old.parentNode === realParent) realParent.removeChild(old);
|
|
165
|
+
}
|
|
166
|
+
currentNodes = [];
|
|
167
|
+
|
|
168
|
+
// Add new nodes before endMarker
|
|
169
|
+
for (const v of vnodes) {
|
|
170
|
+
const node = createDOM(v, realParent, parent?._isSvg);
|
|
171
|
+
if (node) {
|
|
172
|
+
// If createDOM returned a DocumentFragment, track individual children
|
|
173
|
+
// since fragment nodes get absorbed into the DOM on insertion.
|
|
174
|
+
if (node.nodeType === 11 /* DOCUMENT_FRAGMENT_NODE */) {
|
|
175
|
+
const children = Array.from(node.childNodes);
|
|
176
|
+
realParent.insertBefore(node, endMarker);
|
|
177
|
+
for (const child of children) currentNodes.push(child);
|
|
178
|
+
} else {
|
|
179
|
+
realParent.insertBefore(node, endMarker);
|
|
180
|
+
currentNodes.push(node);
|
|
181
|
+
}
|
|
177
182
|
}
|
|
178
|
-
} else {
|
|
179
|
-
reconcileChildren(wrapper, vnodes);
|
|
180
183
|
}
|
|
181
184
|
});
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
|
|
186
|
+
startMarker._dispose = dispose;
|
|
187
|
+
// Also store dispose on endMarker so disposeTree can find it from either marker
|
|
188
|
+
endMarker._dispose = dispose;
|
|
189
|
+
return frag;
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
// Array
|
|
192
|
+
// Array of vnodes
|
|
187
193
|
if (Array.isArray(vnode)) {
|
|
188
194
|
const frag = document.createDocumentFragment();
|
|
189
195
|
for (const child of vnode) {
|
|
@@ -193,48 +199,22 @@ export function createDOM(vnode, parent, isSvg) {
|
|
|
193
199
|
return frag;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
//
|
|
197
|
-
if (
|
|
198
|
-
return document.createTextNode(String(vnode));
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Portal (string-tagged vnodes from helpers.js Portal or react-compat createPortal)
|
|
202
|
-
if (vnode.tag === '__portal') {
|
|
203
|
-
return createPortalDOM(vnode, parent);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Component
|
|
207
|
-
if (typeof vnode.tag === 'function') {
|
|
202
|
+
// VNode with component tag — component runs ONCE
|
|
203
|
+
if (isVNode(vnode) && typeof vnode.tag === 'function') {
|
|
208
204
|
return createComponent(vnode, parent, isSvg);
|
|
209
205
|
}
|
|
210
206
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
// HTML or SVG Element
|
|
215
|
-
const el = svgContext
|
|
216
|
-
? document.createElementNS(SVG_NS, vnode.tag)
|
|
217
|
-
: document.createElement(vnode.tag);
|
|
218
|
-
|
|
219
|
-
applyProps(el, vnode.props, {}, svgContext);
|
|
220
|
-
const hasRawHtml = vnode.props && (
|
|
221
|
-
Object.prototype.hasOwnProperty.call(vnode.props, 'dangerouslySetInnerHTML') ||
|
|
222
|
-
Object.prototype.hasOwnProperty.call(vnode.props, 'innerHTML')
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
if (!hasRawHtml) {
|
|
226
|
-
for (const child of vnode.children) {
|
|
227
|
-
const node = createDOM(child, el, svgContext && vnode.tag !== 'foreignObject');
|
|
228
|
-
if (node) el.appendChild(node);
|
|
229
|
-
}
|
|
207
|
+
// VNode with string tag — create element
|
|
208
|
+
if (isVNode(vnode)) {
|
|
209
|
+
return createElementFromVNode(vnode, parent, isSvg);
|
|
230
210
|
}
|
|
231
211
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
return el;
|
|
212
|
+
// Unknown — convert to text
|
|
213
|
+
return document.createTextNode(String(vnode));
|
|
235
214
|
}
|
|
236
215
|
|
|
237
216
|
// --- Component Rendering ---
|
|
217
|
+
// Components run ONCE. Props are passed as a signal for reactive access.
|
|
238
218
|
|
|
239
219
|
const componentStack = [];
|
|
240
220
|
|
|
@@ -253,9 +233,7 @@ export function getComponentStack() {
|
|
|
253
233
|
function createComponent(vnode, parent, isSvg) {
|
|
254
234
|
let { tag: Component, props, children } = vnode;
|
|
255
235
|
|
|
256
|
-
// Class component detection
|
|
257
|
-
// React compat layer wraps class components in createElement, but some
|
|
258
|
-
// library-internal components may bypass that path. Detect and wrap here.
|
|
236
|
+
// Class component detection
|
|
259
237
|
if (typeof Component === 'function' &&
|
|
260
238
|
(Component.prototype?.isReactComponent || Component.prototype?.render)) {
|
|
261
239
|
const ClassComp = Component;
|
|
@@ -273,7 +251,7 @@ function createComponent(vnode, parent, isSvg) {
|
|
|
273
251
|
if (Component === '__suspense' || vnode.tag === '__suspense') {
|
|
274
252
|
return createSuspenseBoundary(vnode, parent);
|
|
275
253
|
}
|
|
276
|
-
if (Component === '__portal' || vnode.tag === '__portal') {
|
|
254
|
+
if (Component === '__portal' || vnode.tag === '__portal') {
|
|
277
255
|
return createPortalDOM(vnode, parent);
|
|
278
256
|
}
|
|
279
257
|
|
|
@@ -285,9 +263,8 @@ function createComponent(vnode, parent, isSvg) {
|
|
|
285
263
|
cleanups: [],
|
|
286
264
|
mounted: false,
|
|
287
265
|
disposed: false,
|
|
288
|
-
Component,
|
|
266
|
+
Component,
|
|
289
267
|
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
290
|
-
// Inherit error boundary from parent context chain
|
|
291
268
|
_errorBoundary: (() => {
|
|
292
269
|
let p = componentStack[componentStack.length - 1];
|
|
293
270
|
while (p) {
|
|
@@ -298,82 +275,96 @@ function createComponent(vnode, parent, isSvg) {
|
|
|
298
275
|
})()
|
|
299
276
|
};
|
|
300
277
|
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
278
|
+
// Component boundaries: use comment nodes instead of <span style="display:contents">
|
|
279
|
+
// to avoid DOM pollution, CSS selector breakage, and a11y issues.
|
|
280
|
+
const startComment = document.createComment('c:start');
|
|
281
|
+
const endComment = document.createComment('c:end');
|
|
282
|
+
_commentCtxMap.set(startComment, ctx);
|
|
283
|
+
ctx._startComment = startComment;
|
|
284
|
+
ctx._endComment = endComment;
|
|
285
|
+
|
|
286
|
+
// Fragment to hold comment boundaries + component output
|
|
287
|
+
const container = document.createDocumentFragment();
|
|
288
|
+
container._componentCtx = ctx;
|
|
289
|
+
ctx._wrapper = startComment; // Reference for context lookup
|
|
312
290
|
|
|
313
291
|
// Track for disposal
|
|
314
292
|
mountedComponents.add(ctx);
|
|
315
293
|
if (__DEV__ && __devtools?.onComponentMount) __devtools.onComponentMount(ctx);
|
|
316
294
|
|
|
317
295
|
// Props signal for reactive updates from parent
|
|
318
|
-
// Match React's children semantics: 0→undefined, 1→single child, N→array
|
|
319
296
|
const propsChildren = children.length === 0 ? undefined : children.length === 1 ? children[0] : children;
|
|
320
297
|
const propsSignal = signal({ ...props, children: propsChildren });
|
|
321
298
|
ctx._propsSignal = propsSignal;
|
|
322
299
|
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
300
|
+
// Create a reactive props proxy: reading any prop inside an effect
|
|
301
|
+
// will auto-track the dependency on the propsSignal. This makes prop
|
|
302
|
+
// access reactive across re-renders without requiring the component
|
|
303
|
+
// to be re-executed.
|
|
304
|
+
const reactiveProps = new Proxy({}, {
|
|
305
|
+
get(_, key) {
|
|
306
|
+
// Access the signal to create a reactive dependency
|
|
307
|
+
const current = propsSignal();
|
|
308
|
+
return current[key];
|
|
309
|
+
},
|
|
310
|
+
has(_, key) {
|
|
311
|
+
const current = propsSignal();
|
|
312
|
+
return key in current;
|
|
313
|
+
},
|
|
314
|
+
ownKeys() {
|
|
315
|
+
const current = propsSignal();
|
|
316
|
+
return Reflect.ownKeys(current);
|
|
317
|
+
},
|
|
318
|
+
getOwnPropertyDescriptor(_, key) {
|
|
319
|
+
const current = propsSignal();
|
|
320
|
+
if (key in current) {
|
|
321
|
+
return { value: current[key], writable: false, enumerable: true, configurable: true };
|
|
338
322
|
}
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// Keep ctx on componentStack while creating/reconciling children
|
|
343
|
-
// so child components' _parentCtx correctly points to this component.
|
|
344
|
-
// This is essential for context propagation (useContext walks _parentCtx).
|
|
323
|
+
return undefined;
|
|
324
|
+
},
|
|
325
|
+
});
|
|
345
326
|
|
|
346
|
-
|
|
327
|
+
// Component runs ONCE — not inside an effect
|
|
328
|
+
componentStack.push(ctx);
|
|
347
329
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
330
|
+
let result;
|
|
331
|
+
try {
|
|
332
|
+
result = Component(reactiveProps);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
componentStack.pop();
|
|
335
|
+
if (!reportError(error, ctx)) {
|
|
336
|
+
console.error('[what] Uncaught error in component:', Component.name || 'Anonymous', error);
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
// Return fragment with just comment boundaries on error
|
|
340
|
+
container.appendChild(startComment);
|
|
341
|
+
container.appendChild(endComment);
|
|
342
|
+
return container;
|
|
343
|
+
}
|
|
351
344
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
queueMicrotask(() => {
|
|
355
|
-
if (ctx.disposed) return;
|
|
356
|
-
for (const fn of ctx._mountCallbacks) {
|
|
357
|
-
try { fn(); } catch (e) { console.error('[what] onMount error:', e); }
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
}
|
|
345
|
+
componentStack.pop();
|
|
346
|
+
ctx.mounted = true;
|
|
361
347
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
348
|
+
// Run onMount callbacks after DOM is ready
|
|
349
|
+
if (ctx._mountCallbacks) {
|
|
350
|
+
queueMicrotask(() => {
|
|
351
|
+
if (ctx.disposed) return;
|
|
352
|
+
for (const fn of ctx._mountCallbacks) {
|
|
353
|
+
try { fn(); } catch (e) { console.error('[what] onMount error:', e); }
|
|
365
354
|
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
reconcileChildren(wrapper, vnodes);
|
|
369
|
-
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
370
357
|
|
|
371
|
-
|
|
372
|
-
|
|
358
|
+
// Build fragment: <!-- c:start --> [component output] <!-- c:end -->
|
|
359
|
+
container.appendChild(startComment);
|
|
360
|
+
const vnodes = Array.isArray(result) ? result : [result];
|
|
361
|
+
for (const v of vnodes) {
|
|
362
|
+
const node = createDOM(v, container, isSvg);
|
|
363
|
+
if (node) container.appendChild(node);
|
|
364
|
+
}
|
|
365
|
+
container.appendChild(endComment);
|
|
373
366
|
|
|
374
|
-
|
|
375
|
-
wrapper._vnode = vnode; // Store vnode for keyed reconciliation
|
|
376
|
-
return wrapper;
|
|
367
|
+
return container;
|
|
377
368
|
}
|
|
378
369
|
|
|
379
370
|
// Error boundary component handler
|
|
@@ -381,24 +372,40 @@ function createErrorBoundary(vnode, parent) {
|
|
|
381
372
|
const { errorState, handleError, fallback, reset } = vnode.props;
|
|
382
373
|
const children = vnode.children;
|
|
383
374
|
|
|
384
|
-
|
|
385
|
-
|
|
375
|
+
// Use comment node boundaries instead of <span style="display:contents">
|
|
376
|
+
// to avoid DOM pollution, CSS selector breakage, and a11y issues.
|
|
377
|
+
const startComment = document.createComment('eb:start');
|
|
378
|
+
const endComment = document.createComment('eb:end');
|
|
386
379
|
|
|
387
|
-
// Create a boundary context so child components can find this boundary via _parentCtx chain
|
|
388
380
|
const boundaryCtx = {
|
|
389
381
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
390
382
|
mounted: false, disposed: false,
|
|
391
383
|
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
392
384
|
_errorBoundary: handleError,
|
|
385
|
+
_startComment: startComment,
|
|
386
|
+
_endComment: endComment,
|
|
393
387
|
};
|
|
394
|
-
|
|
388
|
+
_commentCtxMap.set(startComment, boundaryCtx);
|
|
389
|
+
|
|
390
|
+
const container = document.createDocumentFragment();
|
|
391
|
+
container._componentCtx = boundaryCtx;
|
|
392
|
+
container.appendChild(startComment);
|
|
393
|
+
container.appendChild(endComment);
|
|
395
394
|
|
|
396
395
|
const dispose = effect(() => {
|
|
397
396
|
const error = errorState();
|
|
398
397
|
|
|
399
|
-
// Push boundary context so child components inherit _errorBoundary via _parentCtx
|
|
400
398
|
componentStack.push(boundaryCtx);
|
|
401
399
|
|
|
400
|
+
// Remove old content between comment boundaries
|
|
401
|
+
if (startComment.parentNode) {
|
|
402
|
+
while (startComment.nextSibling && startComment.nextSibling !== endComment) {
|
|
403
|
+
const old = startComment.nextSibling;
|
|
404
|
+
disposeTree(old);
|
|
405
|
+
old.parentNode.removeChild(old);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
402
409
|
let vnodes;
|
|
403
410
|
if (error) {
|
|
404
411
|
vnodes = typeof fallback === 'function' ? [fallback({ error, reset })] : [fallback];
|
|
@@ -408,20 +415,24 @@ function createErrorBoundary(vnode, parent) {
|
|
|
408
415
|
|
|
409
416
|
vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
|
|
410
417
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
418
|
+
for (const v of vnodes) {
|
|
419
|
+
const node = createDOM(v, parent);
|
|
420
|
+
if (node) {
|
|
421
|
+
// Insert before endComment
|
|
422
|
+
if (endComment.parentNode) {
|
|
423
|
+
endComment.parentNode.insertBefore(node, endComment);
|
|
424
|
+
} else {
|
|
425
|
+
// Still in fragment before first mount
|
|
426
|
+
container.insertBefore(node, endComment);
|
|
427
|
+
}
|
|
415
428
|
}
|
|
416
|
-
} else {
|
|
417
|
-
reconcileChildren(wrapper, vnodes);
|
|
418
429
|
}
|
|
419
430
|
|
|
420
431
|
componentStack.pop();
|
|
421
432
|
});
|
|
422
433
|
|
|
423
434
|
boundaryCtx.effects.push(dispose);
|
|
424
|
-
return
|
|
435
|
+
return container;
|
|
425
436
|
}
|
|
426
437
|
|
|
427
438
|
// Suspense boundary component handler
|
|
@@ -429,16 +440,24 @@ function createSuspenseBoundary(vnode, parent) {
|
|
|
429
440
|
const { boundary, fallback, loading } = vnode.props;
|
|
430
441
|
const children = vnode.children;
|
|
431
442
|
|
|
432
|
-
|
|
433
|
-
|
|
443
|
+
// Use comment node boundaries instead of <span style="display:contents">
|
|
444
|
+
// to avoid DOM pollution, CSS selector breakage, and a11y issues.
|
|
445
|
+
const startComment = document.createComment('sb:start');
|
|
446
|
+
const endComment = document.createComment('sb:end');
|
|
434
447
|
|
|
435
|
-
// Create a boundary context to store the dispose function for cleanup
|
|
436
448
|
const boundaryCtx = {
|
|
437
449
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
438
450
|
mounted: false, disposed: false,
|
|
439
451
|
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
452
|
+
_startComment: startComment,
|
|
453
|
+
_endComment: endComment,
|
|
440
454
|
};
|
|
441
|
-
|
|
455
|
+
_commentCtxMap.set(startComment, boundaryCtx);
|
|
456
|
+
|
|
457
|
+
const container = document.createDocumentFragment();
|
|
458
|
+
container._componentCtx = boundaryCtx;
|
|
459
|
+
container.appendChild(startComment);
|
|
460
|
+
container.appendChild(endComment);
|
|
442
461
|
|
|
443
462
|
const dispose = effect(() => {
|
|
444
463
|
const isLoading = loading();
|
|
@@ -447,23 +466,36 @@ function createSuspenseBoundary(vnode, parent) {
|
|
|
447
466
|
|
|
448
467
|
componentStack.push(boundaryCtx);
|
|
449
468
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
469
|
+
// Remove old content between comment boundaries
|
|
470
|
+
if (startComment.parentNode) {
|
|
471
|
+
while (startComment.nextSibling && startComment.nextSibling !== endComment) {
|
|
472
|
+
const old = startComment.nextSibling;
|
|
473
|
+
disposeTree(old);
|
|
474
|
+
old.parentNode.removeChild(old);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const v of normalized) {
|
|
479
|
+
const node = createDOM(v, parent);
|
|
480
|
+
if (node) {
|
|
481
|
+
// Insert before endComment
|
|
482
|
+
if (endComment.parentNode) {
|
|
483
|
+
endComment.parentNode.insertBefore(node, endComment);
|
|
484
|
+
} else {
|
|
485
|
+
// Still in fragment before first mount
|
|
486
|
+
container.insertBefore(node, endComment);
|
|
487
|
+
}
|
|
454
488
|
}
|
|
455
|
-
} else {
|
|
456
|
-
reconcileChildren(wrapper, normalized);
|
|
457
489
|
}
|
|
458
490
|
|
|
459
491
|
componentStack.pop();
|
|
460
492
|
});
|
|
461
493
|
|
|
462
494
|
boundaryCtx.effects.push(dispose);
|
|
463
|
-
return
|
|
495
|
+
return container;
|
|
464
496
|
}
|
|
465
497
|
|
|
466
|
-
// Portal component handler
|
|
498
|
+
// Portal component handler
|
|
467
499
|
function createPortalDOM(vnode, parent) {
|
|
468
500
|
const { container } = vnode.props;
|
|
469
501
|
const children = vnode.children;
|
|
@@ -473,18 +505,15 @@ function createPortalDOM(vnode, parent) {
|
|
|
473
505
|
return document.createComment('portal:empty');
|
|
474
506
|
}
|
|
475
507
|
|
|
476
|
-
// Create a boundary context for cleanup
|
|
477
508
|
const portalCtx = {
|
|
478
509
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
479
510
|
mounted: false, disposed: false,
|
|
480
511
|
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
481
512
|
};
|
|
482
513
|
|
|
483
|
-
// Placeholder in the original tree for reconciliation
|
|
484
514
|
const placeholder = document.createComment('portal');
|
|
485
515
|
placeholder._componentCtx = portalCtx;
|
|
486
516
|
|
|
487
|
-
// Render children into the target container
|
|
488
517
|
const portalNodes = [];
|
|
489
518
|
for (const child of children) {
|
|
490
519
|
const node = createDOM(child, container);
|
|
@@ -494,7 +523,6 @@ function createPortalDOM(vnode, parent) {
|
|
|
494
523
|
}
|
|
495
524
|
}
|
|
496
525
|
|
|
497
|
-
// Register cleanup to remove portal nodes when placeholder is disposed
|
|
498
526
|
portalCtx._cleanupCallbacks = [() => {
|
|
499
527
|
for (const node of portalNodes) {
|
|
500
528
|
disposeTree(node);
|
|
@@ -505,525 +533,57 @@ function createPortalDOM(vnode, parent) {
|
|
|
505
533
|
return placeholder;
|
|
506
534
|
}
|
|
507
535
|
|
|
508
|
-
// ---
|
|
509
|
-
//
|
|
510
|
-
// Uses keyed reconciliation with LIS (Longest Increasing Subsequence) for minimal DOM moves.
|
|
511
|
-
|
|
512
|
-
function reconcile(parent, oldNodes, newVNodes, beforeMarker) {
|
|
513
|
-
if (!parent) return;
|
|
514
|
-
|
|
515
|
-
const hasKeys = newVNodes.some(v => v && typeof v === 'object' && v.key != null);
|
|
516
|
-
|
|
517
|
-
if (hasKeys) {
|
|
518
|
-
reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker);
|
|
519
|
-
} else {
|
|
520
|
-
reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Unkeyed reconciliation (index-based, fast for static lists)
|
|
525
|
-
function reconcileUnkeyed(parent, oldNodes, newVNodes, beforeMarker) {
|
|
526
|
-
const maxLen = Math.max(oldNodes.length, newVNodes.length);
|
|
527
|
-
const newNodes = [];
|
|
528
|
-
|
|
529
|
-
for (let i = 0; i < maxLen; i++) {
|
|
530
|
-
const oldNode = oldNodes[i];
|
|
531
|
-
const newVNode = newVNodes[i];
|
|
536
|
+
// --- Create Element from VNode ---
|
|
537
|
+
// For h()-based VNodes with string tags
|
|
532
538
|
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (oldNode && oldNode.parentNode) {
|
|
536
|
-
disposeTree(oldNode);
|
|
537
|
-
oldNode.parentNode.removeChild(oldNode);
|
|
538
|
-
}
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
539
|
+
function createElementFromVNode(vnode, parent, isSvg) {
|
|
540
|
+
const { tag, props, children } = vnode;
|
|
541
541
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const ref = getInsertionRef(oldNodes, beforeMarker);
|
|
547
|
-
safeInsertBefore(parent, node, ref);
|
|
548
|
-
newNodes.push(node);
|
|
549
|
-
}
|
|
550
|
-
continue;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Patch existing node
|
|
554
|
-
const patched = patchNode(parent, oldNode, newVNode);
|
|
555
|
-
newNodes.push(patched);
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Update the reference array
|
|
559
|
-
oldNodes.length = 0;
|
|
560
|
-
oldNodes.push(...newNodes);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Keyed reconciliation with LIS algorithm for O(n log n) minimal moves
|
|
564
|
-
function reconcileKeyed(parent, oldNodes, newVNodes, beforeMarker) {
|
|
565
|
-
const newLen = newVNodes.length;
|
|
566
|
-
const oldLen = oldNodes.length;
|
|
567
|
-
|
|
568
|
-
// --- Fast path: same-position keys (covers "update N items in-place") ---
|
|
569
|
-
// If same length and all keys match at the same index, skip LIS entirely.
|
|
570
|
-
// Just patch each node in-place — O(n) with zero DOM moves.
|
|
571
|
-
if (newLen === oldLen && newLen > 0) {
|
|
572
|
-
let allMatch = true;
|
|
573
|
-
for (let i = 0; i < newLen; i++) {
|
|
574
|
-
const newKey = newVNodes[i]?.key;
|
|
575
|
-
const oldKey = oldNodes[i]?._vnode?.key;
|
|
576
|
-
if (newKey == null || newKey !== oldKey) {
|
|
577
|
-
allMatch = false;
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
if (allMatch) {
|
|
582
|
-
for (let i = 0; i < newLen; i++) {
|
|
583
|
-
patchNode(parent, oldNodes[i], newVNodes[i]);
|
|
584
|
-
}
|
|
585
|
-
return;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Build old key -> { node, index } map
|
|
590
|
-
const oldKeyMap = new Map();
|
|
591
|
-
for (let i = 0; i < oldLen; i++) {
|
|
592
|
-
const node = oldNodes[i];
|
|
593
|
-
const key = node._vnode?.key;
|
|
594
|
-
if (key != null) {
|
|
595
|
-
oldKeyMap.set(key, { node, index: i });
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
const newNodes = [];
|
|
600
|
-
|
|
601
|
-
// First pass: match keys and find reusable nodes
|
|
602
|
-
const sources = new Array(newLen).fill(-1); // Maps new index to old index
|
|
603
|
-
const reused = new Set();
|
|
604
|
-
|
|
605
|
-
for (let i = 0; i < newLen; i++) {
|
|
606
|
-
const vnode = newVNodes[i];
|
|
607
|
-
const key = vnode?.key;
|
|
608
|
-
if (key != null && oldKeyMap.has(key)) {
|
|
609
|
-
const { node: oldNode, index: oldIndex } = oldKeyMap.get(key);
|
|
610
|
-
sources[i] = oldIndex;
|
|
611
|
-
reused.add(oldIndex);
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Remove nodes that aren't reused
|
|
616
|
-
for (let i = 0; i < oldLen; i++) {
|
|
617
|
-
if (!reused.has(i) && oldNodes[i]?.parentNode) {
|
|
618
|
-
disposeTree(oldNodes[i]);
|
|
619
|
-
oldNodes[i].parentNode.removeChild(oldNodes[i]);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Find LIS (Longest Increasing Subsequence) of old indices.
|
|
624
|
-
// The LIS tells us which reused nodes are already in correct relative order
|
|
625
|
-
// and don't need to be moved. Only nodes NOT in the LIS need DOM moves.
|
|
626
|
-
//
|
|
627
|
-
// Step 1: Filter out -1 entries (new nodes with no old counterpart).
|
|
628
|
-
// Step 2: Compute LIS on the filtered array. Result: indices into the filtered array.
|
|
629
|
-
// Step 3: Map filtered-array indices back to original sources[] indices (new-VNode indices).
|
|
630
|
-
// For each LIS index `lis[i]`, we find the `lis[i]`-th non-negative entry in sources[]
|
|
631
|
-
// and return its position in the original sources array.
|
|
632
|
-
// Build filteredToOriginal map in one O(n) pass instead of O(n²) nested loop
|
|
633
|
-
const filtered = [];
|
|
634
|
-
const filteredToOriginal = [];
|
|
635
|
-
for (let j = 0; j < sources.length; j++) {
|
|
636
|
-
if (sources[j] !== -1) {
|
|
637
|
-
filteredToOriginal.push(j);
|
|
638
|
-
filtered.push(sources[j]);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
const lis = longestIncreasingSubsequence(filtered);
|
|
642
|
-
const lisSet = new Set(lis.map(i => filteredToOriginal[i]));
|
|
643
|
-
|
|
644
|
-
// Build new nodes array and move/create as needed
|
|
645
|
-
let lastInserted = beforeMarker?.nextSibling || null;
|
|
646
|
-
|
|
647
|
-
// Process in reverse order for correct insertion
|
|
648
|
-
for (let i = newLen - 1; i >= 0; i--) {
|
|
649
|
-
const vnode = newVNodes[i];
|
|
650
|
-
const key = vnode?.key;
|
|
651
|
-
const oldEntry = key != null ? oldKeyMap.get(key) : null;
|
|
652
|
-
|
|
653
|
-
if (oldEntry && sources[i] !== -1) {
|
|
654
|
-
// Reuse existing node
|
|
655
|
-
const oldNode = oldEntry.node;
|
|
656
|
-
// Patch props/children
|
|
657
|
-
const patched = patchNode(parent, oldNode, vnode);
|
|
658
|
-
newNodes[i] = patched;
|
|
659
|
-
|
|
660
|
-
// Move if not in LIS
|
|
661
|
-
if (!lisSet.has(i) && patched.parentNode) {
|
|
662
|
-
safeInsertBefore(parent, patched, lastInserted);
|
|
663
|
-
}
|
|
664
|
-
lastInserted = patched;
|
|
665
|
-
} else {
|
|
666
|
-
// Create new node
|
|
667
|
-
const node = createDOM(vnode, parent);
|
|
668
|
-
if (node) {
|
|
669
|
-
safeInsertBefore(parent, node, lastInserted);
|
|
670
|
-
lastInserted = node;
|
|
671
|
-
}
|
|
672
|
-
newNodes[i] = node;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
// Update the reference array
|
|
677
|
-
oldNodes.length = 0;
|
|
678
|
-
oldNodes.push(...newNodes.filter(Boolean));
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Longest Increasing Subsequence - O(n log n)
|
|
682
|
-
// Returns indices of elements that form the LIS
|
|
683
|
-
function longestIncreasingSubsequence(arr) {
|
|
684
|
-
if (arr.length === 0) return [];
|
|
685
|
-
|
|
686
|
-
const n = arr.length;
|
|
687
|
-
const dp = new Array(n).fill(1); // Length of LIS ending at i
|
|
688
|
-
const parent = new Array(n).fill(-1); // Parent index for reconstruction
|
|
689
|
-
const tails = [0]; // Indices of smallest tail elements
|
|
690
|
-
|
|
691
|
-
for (let i = 1; i < n; i++) {
|
|
692
|
-
if (arr[i] > arr[tails[tails.length - 1]]) {
|
|
693
|
-
parent[i] = tails[tails.length - 1];
|
|
694
|
-
tails.push(i);
|
|
695
|
-
} else {
|
|
696
|
-
// Binary search for the smallest element >= arr[i]
|
|
697
|
-
let lo = 0, hi = tails.length - 1;
|
|
698
|
-
while (lo < hi) {
|
|
699
|
-
const mid = (lo + hi) >> 1;
|
|
700
|
-
if (arr[tails[mid]] < arr[i]) lo = mid + 1;
|
|
701
|
-
else hi = mid;
|
|
702
|
-
}
|
|
703
|
-
if (arr[i] < arr[tails[lo]]) {
|
|
704
|
-
if (lo > 0) parent[i] = tails[lo - 1];
|
|
705
|
-
tails[lo] = i;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
// Reconstruct LIS
|
|
711
|
-
const result = [];
|
|
712
|
-
let k = tails[tails.length - 1];
|
|
713
|
-
while (k !== -1) {
|
|
714
|
-
result.push(k);
|
|
715
|
-
k = parent[k];
|
|
716
|
-
}
|
|
717
|
-
return result.reverse();
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function getInsertionRef(nodes, marker) {
|
|
721
|
-
if (nodes.length > 0) {
|
|
722
|
-
const last = nodes[nodes.length - 1];
|
|
723
|
-
return last.nextSibling;
|
|
724
|
-
}
|
|
725
|
-
return marker ? marker.nextSibling : null;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
// Safe insertBefore: guards against stale reference nodes from nested reconciliation.
|
|
729
|
-
// When patchNode triggers a child component re-render (via propsSignal.set), the child's
|
|
730
|
-
// effect can run synchronously and mutate the DOM tree, leaving the parent's reference
|
|
731
|
-
// node detached. This helper falls back to appendChild when the ref is stale.
|
|
732
|
-
function safeInsertBefore(parent, node, ref) {
|
|
733
|
-
if (ref && ref.parentNode === parent) {
|
|
734
|
-
parent.insertBefore(node, ref);
|
|
735
|
-
} else {
|
|
736
|
-
parent.appendChild(node);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
// Helper: clean up array marker range (startMarker .. endMarker) and return a clean replacement node
|
|
741
|
-
function cleanupArrayMarkers(parent, startMarker) {
|
|
742
|
-
const endMarker = startMarker._arrayEnd;
|
|
743
|
-
if (!endMarker) return null;
|
|
744
|
-
// Remove all nodes between start and end markers
|
|
745
|
-
let node = startMarker.nextSibling;
|
|
746
|
-
while (node && node !== endMarker) {
|
|
747
|
-
const next = node.nextSibling;
|
|
748
|
-
disposeTree(node);
|
|
749
|
-
parent.removeChild(node);
|
|
750
|
-
node = next;
|
|
751
|
-
}
|
|
752
|
-
// Remove end marker
|
|
753
|
-
if (endMarker.parentNode) parent.removeChild(endMarker);
|
|
754
|
-
return startMarker;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function patchNode(parent, domNode, vnode) {
|
|
758
|
-
// Null/removed → keep placeholder or replace with one
|
|
759
|
-
if (vnode == null || vnode === false || vnode === true) {
|
|
760
|
-
// Handle array marker cleanup
|
|
761
|
-
if (domNode && domNode.nodeType === 8 && domNode._arrayEnd) {
|
|
762
|
-
cleanupArrayMarkers(parent, domNode);
|
|
763
|
-
const placeholder = document.createComment('');
|
|
764
|
-
parent.replaceChild(placeholder, domNode);
|
|
765
|
-
return placeholder;
|
|
766
|
-
}
|
|
767
|
-
if (domNode && domNode.nodeType === 8 && !domNode._componentCtx) {
|
|
768
|
-
return domNode; // already a placeholder comment
|
|
769
|
-
}
|
|
770
|
-
const placeholder = document.createComment('');
|
|
771
|
-
if (domNode && domNode.parentNode) {
|
|
772
|
-
disposeTree(domNode);
|
|
773
|
-
parent.replaceChild(placeholder, domNode);
|
|
774
|
-
}
|
|
775
|
-
return placeholder;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Reactive function child — replace whatever's there with a reactive wrapper
|
|
779
|
-
if (typeof vnode === 'function') {
|
|
780
|
-
const wrapper = document.createElement('what-c');
|
|
781
|
-
let mounted = false;
|
|
782
|
-
const dispose = effect(() => {
|
|
783
|
-
const val = vnode();
|
|
784
|
-
const vnodes = (val == null || val === false || val === true)
|
|
785
|
-
? []
|
|
786
|
-
: Array.isArray(val) ? val : [val];
|
|
787
|
-
if (!mounted) {
|
|
788
|
-
mounted = true;
|
|
789
|
-
for (const v of vnodes) {
|
|
790
|
-
const node = createDOM(v, wrapper);
|
|
791
|
-
if (node) wrapper.appendChild(node);
|
|
792
|
-
}
|
|
793
|
-
} else {
|
|
794
|
-
reconcileChildren(wrapper, vnodes);
|
|
795
|
-
}
|
|
796
|
-
});
|
|
797
|
-
wrapper._dispose = dispose;
|
|
798
|
-
if (domNode && domNode.parentNode) {
|
|
799
|
-
disposeTree(domNode);
|
|
800
|
-
parent.replaceChild(wrapper, domNode);
|
|
801
|
-
}
|
|
802
|
-
return wrapper;
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
// DOM node passthrough
|
|
806
|
-
if (isDomNode(vnode)) {
|
|
807
|
-
if (domNode === vnode) return domNode;
|
|
808
|
-
if (domNode && domNode.parentNode) {
|
|
809
|
-
disposeTree(domNode);
|
|
810
|
-
parent.replaceChild(vnode, domNode);
|
|
811
|
-
}
|
|
812
|
-
return vnode;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// Text
|
|
816
|
-
if (typeof vnode === 'string' || typeof vnode === 'number') {
|
|
817
|
-
const text = String(vnode);
|
|
818
|
-
// Clean up array markers if transitioning from array to text
|
|
819
|
-
if (domNode && domNode.nodeType === 8 && domNode._arrayEnd) {
|
|
820
|
-
cleanupArrayMarkers(parent, domNode);
|
|
821
|
-
const newNode = document.createTextNode(text);
|
|
822
|
-
parent.replaceChild(newNode, domNode);
|
|
823
|
-
return newNode;
|
|
824
|
-
}
|
|
825
|
-
if (domNode.nodeType === 3) {
|
|
826
|
-
if (domNode.textContent !== text) domNode.textContent = text;
|
|
827
|
-
return domNode;
|
|
828
|
-
}
|
|
829
|
-
const newNode = document.createTextNode(text);
|
|
830
|
-
disposeTree(domNode);
|
|
831
|
-
parent.replaceChild(newNode, domNode);
|
|
832
|
-
return newNode;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Array — use marker comments to bracket the range (DocumentFragment empties on append)
|
|
836
|
-
if (Array.isArray(vnode)) {
|
|
837
|
-
// If domNode is already an array marker, reconcile contents in place
|
|
838
|
-
if (domNode && domNode.nodeType === 8 && domNode._arrayEnd) {
|
|
839
|
-
const endMarker = domNode._arrayEnd;
|
|
840
|
-
// Collect existing children between markers
|
|
841
|
-
const oldChildren = [];
|
|
842
|
-
let node = domNode.nextSibling;
|
|
843
|
-
while (node && node !== endMarker) {
|
|
844
|
-
oldChildren.push(node);
|
|
845
|
-
node = node.nextSibling;
|
|
846
|
-
}
|
|
847
|
-
// Reconcile the array contents
|
|
848
|
-
const maxLen = Math.max(oldChildren.length, vnode.length);
|
|
849
|
-
for (let i = 0; i < maxLen; i++) {
|
|
850
|
-
if (i >= vnode.length) {
|
|
851
|
-
// Remove extra old nodes
|
|
852
|
-
if (oldChildren[i]?.parentNode) {
|
|
853
|
-
disposeTree(oldChildren[i]);
|
|
854
|
-
parent.removeChild(oldChildren[i]);
|
|
855
|
-
}
|
|
856
|
-
} else if (i >= oldChildren.length) {
|
|
857
|
-
// Append new nodes before end marker
|
|
858
|
-
const newNode = createDOM(vnode[i], parent);
|
|
859
|
-
if (newNode) parent.insertBefore(newNode, endMarker);
|
|
860
|
-
} else {
|
|
861
|
-
// Patch existing
|
|
862
|
-
patchNode(parent, oldChildren[i], vnode[i]);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
return domNode;
|
|
866
|
-
}
|
|
867
|
-
// Fresh array: create markers
|
|
868
|
-
const startMarker = document.createComment('[');
|
|
869
|
-
const endMarker = document.createComment(']');
|
|
870
|
-
disposeTree(domNode);
|
|
871
|
-
parent.replaceChild(endMarker, domNode);
|
|
872
|
-
parent.insertBefore(startMarker, endMarker);
|
|
873
|
-
for (const v of vnode) {
|
|
874
|
-
const node = createDOM(v, parent);
|
|
875
|
-
if (node) parent.insertBefore(node, endMarker);
|
|
876
|
-
}
|
|
877
|
-
startMarker._arrayEnd = endMarker;
|
|
878
|
-
return startMarker;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// Unknown object child fallback
|
|
882
|
-
if (!isVNode(vnode)) {
|
|
883
|
-
const text = String(vnode);
|
|
884
|
-
if (domNode.nodeType === 3) {
|
|
885
|
-
if (domNode.textContent !== text) domNode.textContent = text;
|
|
886
|
-
return domNode;
|
|
887
|
-
}
|
|
888
|
-
const newNode = document.createTextNode(text);
|
|
889
|
-
disposeTree(domNode);
|
|
890
|
-
parent.replaceChild(newNode, domNode);
|
|
891
|
-
return newNode;
|
|
892
|
-
}
|
|
542
|
+
const svgContext = isSvg || SVG_ELEMENTS.has(tag);
|
|
543
|
+
const el = svgContext
|
|
544
|
+
? document.createElementNS(SVG_NS, tag)
|
|
545
|
+
: document.createElement(tag);
|
|
893
546
|
|
|
894
|
-
//
|
|
895
|
-
if (
|
|
896
|
-
|
|
897
|
-
if (domNode._componentCtx && !domNode._componentCtx.disposed
|
|
898
|
-
&& domNode._componentCtx.Component === vnode.tag) {
|
|
899
|
-
// Same component — update props reactively, let its effect re-render
|
|
900
|
-
const ch = vnode.children;
|
|
901
|
-
const patchChildren = ch.length === 0 ? undefined : ch.length === 1 ? ch[0] : ch;
|
|
902
|
-
const nextProps = { ...vnode.props, children: patchChildren };
|
|
903
|
-
// Skip signal update if props haven't changed (shallow compare)
|
|
904
|
-
const prevProps = domNode._componentCtx._propsSignal.peek();
|
|
905
|
-
if (!shallowEqual(prevProps, nextProps)) {
|
|
906
|
-
domNode._componentCtx._propsSignal.set(nextProps);
|
|
907
|
-
}
|
|
908
|
-
domNode._vnode = vnode; // Keep vnode current for keyed reconciliation
|
|
909
|
-
return domNode;
|
|
910
|
-
}
|
|
911
|
-
// Different component or not a component — dispose old, create new
|
|
912
|
-
disposeTree(domNode);
|
|
913
|
-
const node = createComponent(vnode, parent);
|
|
914
|
-
parent.replaceChild(node, domNode);
|
|
915
|
-
return node;
|
|
547
|
+
// Apply props
|
|
548
|
+
if (props) {
|
|
549
|
+
applyProps(el, props, {}, svgContext);
|
|
916
550
|
}
|
|
917
551
|
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
const hadRawHtml = Object.prototype.hasOwnProperty.call(oldProps, 'dangerouslySetInnerHTML')
|
|
923
|
-
|| Object.prototype.hasOwnProperty.call(oldProps, 'innerHTML');
|
|
924
|
-
const hasRawHtml = Object.prototype.hasOwnProperty.call(nextProps, 'dangerouslySetInnerHTML')
|
|
925
|
-
|| Object.prototype.hasOwnProperty.call(nextProps, 'innerHTML');
|
|
926
|
-
|
|
927
|
-
// If switching from normal children to raw HTML, dispose existing child effects first.
|
|
928
|
-
if (hasRawHtml && !hadRawHtml) {
|
|
929
|
-
for (const child of Array.from(domNode.childNodes)) {
|
|
930
|
-
disposeTree(child);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
applyProps(domNode, nextProps, oldProps);
|
|
935
|
-
|
|
936
|
-
// Raw HTML props own the element's children. Skip vnode child reconciliation.
|
|
937
|
-
if (!hasRawHtml) {
|
|
938
|
-
reconcileChildren(domNode, vnode.children);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
domNode._vnode = vnode;
|
|
942
|
-
return domNode;
|
|
552
|
+
// Append children
|
|
553
|
+
for (const child of children) {
|
|
554
|
+
const node = createDOM(child, el, svgContext && tag !== 'foreignObject');
|
|
555
|
+
if (node) el.appendChild(node);
|
|
943
556
|
}
|
|
944
557
|
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
disposeTree(domNode);
|
|
948
|
-
parent.replaceChild(newNode, domNode);
|
|
949
|
-
return newNode;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function reconcileChildren(parent, newChildVNodes) {
|
|
953
|
-
const oldChildren = Array.from(parent.childNodes);
|
|
954
|
-
|
|
955
|
-
// Check for keyed children
|
|
956
|
-
const hasKeys = newChildVNodes.some(v => v && typeof v === 'object' && v.key != null);
|
|
957
|
-
|
|
958
|
-
if (hasKeys) {
|
|
959
|
-
// Use keyed reconciliation
|
|
960
|
-
reconcileKeyed(parent, oldChildren, newChildVNodes, null);
|
|
961
|
-
} else {
|
|
962
|
-
// Unkeyed reconciliation
|
|
963
|
-
const maxLen = Math.max(oldChildren.length, newChildVNodes.length);
|
|
964
|
-
|
|
965
|
-
for (let i = 0; i < maxLen; i++) {
|
|
966
|
-
if (i >= newChildVNodes.length) {
|
|
967
|
-
// Remove extra
|
|
968
|
-
if (oldChildren[i]?.parentNode) {
|
|
969
|
-
disposeTree(oldChildren[i]);
|
|
970
|
-
parent.removeChild(oldChildren[i]);
|
|
971
|
-
}
|
|
972
|
-
continue;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
if (i >= oldChildren.length) {
|
|
976
|
-
// Append new
|
|
977
|
-
const node = createDOM(newChildVNodes[i], parent);
|
|
978
|
-
if (node) parent.appendChild(node);
|
|
979
|
-
continue;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
patchNode(parent, oldChildren[i], newChildVNodes[i]);
|
|
983
|
-
}
|
|
984
|
-
}
|
|
558
|
+
el._vnode = vnode;
|
|
559
|
+
return el;
|
|
985
560
|
}
|
|
986
561
|
|
|
987
|
-
// --- Prop
|
|
988
|
-
// Only
|
|
562
|
+
// --- Prop Application ---
|
|
563
|
+
// Only applied once for fine-grained (no diffing). Reactive props use effects.
|
|
989
564
|
|
|
990
565
|
function applyProps(el, newProps, oldProps, isSvg) {
|
|
991
566
|
newProps = newProps || {};
|
|
992
567
|
oldProps = oldProps || {};
|
|
993
568
|
|
|
994
|
-
// Remove old props not in new
|
|
995
|
-
for (const key in oldProps) {
|
|
996
|
-
if (key === 'key' || key === 'ref' || key === 'children') continue;
|
|
997
|
-
if (!(key in newProps)) {
|
|
998
|
-
removeProp(el, key, oldProps[key]);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
// Set new/changed props
|
|
1003
569
|
for (const key in newProps) {
|
|
1004
|
-
if (key === 'key' || key === '
|
|
1005
|
-
|
|
1006
|
-
|
|
570
|
+
if (key === 'key' || key === 'children') continue;
|
|
571
|
+
|
|
572
|
+
// Handle ref
|
|
573
|
+
if (key === 'ref') {
|
|
574
|
+
if (typeof newProps.ref === 'function') newProps.ref(el);
|
|
575
|
+
else if (newProps.ref) newProps.ref.current = el;
|
|
576
|
+
continue;
|
|
1007
577
|
}
|
|
1008
|
-
}
|
|
1009
578
|
|
|
1010
|
-
|
|
1011
|
-
if (newProps.ref && newProps.ref !== oldProps.ref) {
|
|
1012
|
-
if (typeof newProps.ref === 'function') newProps.ref(el);
|
|
1013
|
-
else newProps.ref.current = el;
|
|
579
|
+
setProp(el, key, newProps[key], isSvg);
|
|
1014
580
|
}
|
|
1015
581
|
}
|
|
1016
582
|
|
|
1017
583
|
function setProp(el, key, value, isSvg) {
|
|
1018
|
-
// Reactive function props — wrap in effect
|
|
1019
|
-
// Applies to any non-event prop where the value is a function, e.g.:
|
|
1020
|
-
// h('input', { value: () => name(), class: () => active() ? 'on' : 'off' })
|
|
1021
|
-
// The function is called inside an effect, so signal reads create subscriptions.
|
|
1022
|
-
// When signals change, the prop is re-applied automatically.
|
|
584
|
+
// Reactive function props — wrap in effect for fine-grained updates
|
|
1023
585
|
if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
|
|
1024
|
-
// Store dispose functions on the element for cleanup
|
|
1025
586
|
if (!el._propEffects) el._propEffects = {};
|
|
1026
|
-
// Dispose previous effect for this prop if re-applying
|
|
1027
587
|
if (el._propEffects[key]) {
|
|
1028
588
|
try { el._propEffects[key](); } catch (e) { /* already disposed */ }
|
|
1029
589
|
}
|
|
@@ -1034,38 +594,27 @@ function setProp(el, key, value, isSvg) {
|
|
|
1034
594
|
return;
|
|
1035
595
|
}
|
|
1036
596
|
|
|
1037
|
-
// Event handlers
|
|
1038
|
-
// Wrap in untrack so signal reads in handlers don't create subscriptions
|
|
597
|
+
// Event handlers
|
|
1039
598
|
if (key.startsWith('on') && key.length > 2) {
|
|
1040
599
|
let eventName = key.slice(2);
|
|
1041
|
-
// React-style capture phase: onClickCapture → click in capture phase
|
|
1042
600
|
let useCapture = false;
|
|
1043
601
|
if (eventName.endsWith('Capture')) {
|
|
1044
602
|
eventName = eventName.slice(0, -7);
|
|
1045
603
|
useCapture = true;
|
|
1046
604
|
}
|
|
1047
605
|
const event = eventName.toLowerCase();
|
|
1048
|
-
// Use a combined key for storage so capture/bubble don't conflict
|
|
1049
606
|
const storageKey = useCapture ? event + '_capture' : event;
|
|
1050
|
-
// Store handler for removal
|
|
1051
607
|
const old = el._events?.[storageKey];
|
|
1052
|
-
// Skip re-wrapping if same handler function
|
|
1053
608
|
if (old && old._original === value) return;
|
|
1054
609
|
if (old) el.removeEventListener(event, old, useCapture);
|
|
1055
|
-
// If handler is null/undefined, just remove the old one and bail
|
|
1056
610
|
if (value == null) return;
|
|
1057
611
|
if (!el._events) el._events = {};
|
|
1058
|
-
// Wrap handler to untrack signal reads.
|
|
1059
|
-
// Add nativeEvent for React compat — React synthetic events have
|
|
1060
|
-
// e.nativeEvent pointing to the actual DOM event. Libraries like
|
|
1061
|
-
// react-colorful, cmdk, and @floating-ui/react check this property.
|
|
1062
612
|
const wrappedHandler = (e) => {
|
|
1063
613
|
if (!e.nativeEvent) e.nativeEvent = e;
|
|
1064
614
|
return untrack(() => value(e));
|
|
1065
615
|
};
|
|
1066
616
|
wrappedHandler._original = value;
|
|
1067
617
|
el._events[storageKey] = wrappedHandler;
|
|
1068
|
-
// Check for _eventOpts (once/capture/passive from compiler)
|
|
1069
618
|
const eventOpts = value._eventOpts;
|
|
1070
619
|
el.addEventListener(event, wrappedHandler, eventOpts || useCapture || undefined);
|
|
1071
620
|
return;
|
|
@@ -1081,13 +630,12 @@ function setProp(el, key, value, isSvg) {
|
|
|
1081
630
|
return;
|
|
1082
631
|
}
|
|
1083
632
|
|
|
1084
|
-
// Style
|
|
633
|
+
// Style
|
|
1085
634
|
if (key === 'style') {
|
|
1086
635
|
if (typeof value === 'string') {
|
|
1087
636
|
el.style.cssText = value;
|
|
1088
637
|
el._prevStyle = null;
|
|
1089
638
|
} else if (typeof value === 'object') {
|
|
1090
|
-
// Remove old style properties not in new style
|
|
1091
639
|
const oldStyle = el._prevStyle || {};
|
|
1092
640
|
for (const prop in oldStyle) {
|
|
1093
641
|
if (!(prop in value)) el.style[prop] = '';
|
|
@@ -1106,12 +654,20 @@ function setProp(el, key, value, isSvg) {
|
|
|
1106
654
|
return;
|
|
1107
655
|
}
|
|
1108
656
|
|
|
1109
|
-
// innerHTML
|
|
657
|
+
// innerHTML — require { __html: ... } wrapper to prevent XSS
|
|
1110
658
|
if (key === 'innerHTML') {
|
|
659
|
+
if (value == null) return; // null/undefined — do nothing
|
|
1111
660
|
if (value && typeof value === 'object' && '__html' in value) {
|
|
1112
661
|
el.innerHTML = value.__html ?? '';
|
|
1113
662
|
} else {
|
|
1114
|
-
|
|
663
|
+
if (__DEV__) {
|
|
664
|
+
console.warn(
|
|
665
|
+
'[what] innerHTML received a raw string. This is a security risk (XSS). ' +
|
|
666
|
+
'Use innerHTML={{ __html: trustedString }} or dangerouslySetInnerHTML={{ __html: trustedString }} instead.'
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
// Refuse to set raw string innerHTML — prevent XSS
|
|
670
|
+
return;
|
|
1115
671
|
}
|
|
1116
672
|
return;
|
|
1117
673
|
}
|
|
@@ -1123,13 +679,13 @@ function setProp(el, key, value, isSvg) {
|
|
|
1123
679
|
return;
|
|
1124
680
|
}
|
|
1125
681
|
|
|
1126
|
-
// data-* and aria-*
|
|
682
|
+
// data-* and aria-*
|
|
1127
683
|
if (key.startsWith('data-') || key.startsWith('aria-')) {
|
|
1128
684
|
el.setAttribute(key, value);
|
|
1129
685
|
return;
|
|
1130
686
|
}
|
|
1131
687
|
|
|
1132
|
-
// SVG
|
|
688
|
+
// SVG
|
|
1133
689
|
if (isSvg) {
|
|
1134
690
|
if (value === false || value == null) {
|
|
1135
691
|
el.removeAttribute(key);
|
|
@@ -1139,46 +695,10 @@ function setProp(el, key, value, isSvg) {
|
|
|
1139
695
|
return;
|
|
1140
696
|
}
|
|
1141
697
|
|
|
1142
|
-
// Default:
|
|
698
|
+
// Default: property if exists, otherwise attribute
|
|
1143
699
|
if (key in el) {
|
|
1144
700
|
el[key] = value;
|
|
1145
701
|
} else {
|
|
1146
702
|
el.setAttribute(key, value);
|
|
1147
703
|
}
|
|
1148
704
|
}
|
|
1149
|
-
|
|
1150
|
-
function removeProp(el, key, oldValue) {
|
|
1151
|
-
if (key.startsWith('on') && key.length > 2) {
|
|
1152
|
-
let eventName = key.slice(2);
|
|
1153
|
-
let useCapture = false;
|
|
1154
|
-
if (eventName.endsWith('Capture')) {
|
|
1155
|
-
eventName = eventName.slice(0, -7);
|
|
1156
|
-
useCapture = true;
|
|
1157
|
-
}
|
|
1158
|
-
const event = eventName.toLowerCase();
|
|
1159
|
-
const storageKey = useCapture ? event + '_capture' : event;
|
|
1160
|
-
if (el._events?.[storageKey]) {
|
|
1161
|
-
el.removeEventListener(event, el._events[storageKey], useCapture);
|
|
1162
|
-
delete el._events[storageKey];
|
|
1163
|
-
}
|
|
1164
|
-
return;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
if (key === 'className' || key === 'class') {
|
|
1168
|
-
el.className = '';
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
if (key === 'style') {
|
|
1173
|
-
el.style.cssText = '';
|
|
1174
|
-
el._prevStyle = null;
|
|
1175
|
-
return;
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
if (key === 'dangerouslySetInnerHTML' || key === 'innerHTML') {
|
|
1179
|
-
el.innerHTML = '';
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
el.removeAttribute(key);
|
|
1184
|
-
}
|