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/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
|
-
}
|
|
323
|
+
return undefined;
|
|
324
|
+
},
|
|
325
|
+
});
|
|
341
326
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
// This is essential for context propagation (useContext walks _parentCtx).
|
|
327
|
+
// Component runs ONCE — not inside an effect
|
|
328
|
+
componentStack.push(ctx);
|
|
345
329
|
|
|
346
|
-
|
|
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
|
+
}
|
|
347
344
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
ctx.mounted = true;
|
|
345
|
+
componentStack.pop();
|
|
346
|
+
ctx.mounted = true;
|
|
351
347
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
});
|
|
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); }
|
|
360
354
|
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
361
357
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
componentStack.pop();
|
|
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,10 +372,9 @@ function createErrorBoundary(vnode, parent) {
|
|
|
381
372
|
const { errorState, handleError, fallback, reset } = vnode.props;
|
|
382
373
|
const children = vnode.children;
|
|
383
374
|
|
|
384
|
-
const wrapper = document.createElement('
|
|
375
|
+
const wrapper = document.createElement('span');
|
|
385
376
|
wrapper.style.display = 'contents';
|
|
386
377
|
|
|
387
|
-
// Create a boundary context so child components can find this boundary via _parentCtx chain
|
|
388
378
|
const boundaryCtx = {
|
|
389
379
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
390
380
|
mounted: false, disposed: false,
|
|
@@ -396,9 +386,14 @@ function createErrorBoundary(vnode, parent) {
|
|
|
396
386
|
const dispose = effect(() => {
|
|
397
387
|
const error = errorState();
|
|
398
388
|
|
|
399
|
-
// Push boundary context so child components inherit _errorBoundary via _parentCtx
|
|
400
389
|
componentStack.push(boundaryCtx);
|
|
401
390
|
|
|
391
|
+
// Remove old content
|
|
392
|
+
while (wrapper.firstChild) {
|
|
393
|
+
disposeTree(wrapper.firstChild);
|
|
394
|
+
wrapper.removeChild(wrapper.firstChild);
|
|
395
|
+
}
|
|
396
|
+
|
|
402
397
|
let vnodes;
|
|
403
398
|
if (error) {
|
|
404
399
|
vnodes = typeof fallback === 'function' ? [fallback({ error, reset })] : [fallback];
|
|
@@ -408,13 +403,9 @@ function createErrorBoundary(vnode, parent) {
|
|
|
408
403
|
|
|
409
404
|
vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
|
|
410
405
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (node) wrapper.appendChild(node);
|
|
415
|
-
}
|
|
416
|
-
} else {
|
|
417
|
-
reconcileChildren(wrapper, vnodes);
|
|
406
|
+
for (const v of vnodes) {
|
|
407
|
+
const node = createDOM(v, wrapper);
|
|
408
|
+
if (node) wrapper.appendChild(node);
|
|
418
409
|
}
|
|
419
410
|
|
|
420
411
|
componentStack.pop();
|
|
@@ -429,10 +420,9 @@ function createSuspenseBoundary(vnode, parent) {
|
|
|
429
420
|
const { boundary, fallback, loading } = vnode.props;
|
|
430
421
|
const children = vnode.children;
|
|
431
422
|
|
|
432
|
-
const wrapper = document.createElement('
|
|
423
|
+
const wrapper = document.createElement('span');
|
|
433
424
|
wrapper.style.display = 'contents';
|
|
434
425
|
|
|
435
|
-
// Create a boundary context to store the dispose function for cleanup
|
|
436
426
|
const boundaryCtx = {
|
|
437
427
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
438
428
|
mounted: false, disposed: false,
|
|
@@ -447,13 +437,15 @@ function createSuspenseBoundary(vnode, parent) {
|
|
|
447
437
|
|
|
448
438
|
componentStack.push(boundaryCtx);
|
|
449
439
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
440
|
+
// Remove old content
|
|
441
|
+
while (wrapper.firstChild) {
|
|
442
|
+
disposeTree(wrapper.firstChild);
|
|
443
|
+
wrapper.removeChild(wrapper.firstChild);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const v of normalized) {
|
|
447
|
+
const node = createDOM(v, wrapper);
|
|
448
|
+
if (node) wrapper.appendChild(node);
|
|
457
449
|
}
|
|
458
450
|
|
|
459
451
|
componentStack.pop();
|
|
@@ -463,7 +455,7 @@ function createSuspenseBoundary(vnode, parent) {
|
|
|
463
455
|
return wrapper;
|
|
464
456
|
}
|
|
465
457
|
|
|
466
|
-
// Portal component handler
|
|
458
|
+
// Portal component handler
|
|
467
459
|
function createPortalDOM(vnode, parent) {
|
|
468
460
|
const { container } = vnode.props;
|
|
469
461
|
const children = vnode.children;
|
|
@@ -473,18 +465,15 @@ function createPortalDOM(vnode, parent) {
|
|
|
473
465
|
return document.createComment('portal:empty');
|
|
474
466
|
}
|
|
475
467
|
|
|
476
|
-
// Create a boundary context for cleanup
|
|
477
468
|
const portalCtx = {
|
|
478
469
|
hooks: [], hookIndex: 0, effects: [], cleanups: [],
|
|
479
470
|
mounted: false, disposed: false,
|
|
480
471
|
_parentCtx: componentStack[componentStack.length - 1] || null,
|
|
481
472
|
};
|
|
482
473
|
|
|
483
|
-
// Placeholder in the original tree for reconciliation
|
|
484
474
|
const placeholder = document.createComment('portal');
|
|
485
475
|
placeholder._componentCtx = portalCtx;
|
|
486
476
|
|
|
487
|
-
// Render children into the target container
|
|
488
477
|
const portalNodes = [];
|
|
489
478
|
for (const child of children) {
|
|
490
479
|
const node = createDOM(child, container);
|
|
@@ -494,7 +483,6 @@ function createPortalDOM(vnode, parent) {
|
|
|
494
483
|
}
|
|
495
484
|
}
|
|
496
485
|
|
|
497
|
-
// Register cleanup to remove portal nodes when placeholder is disposed
|
|
498
486
|
portalCtx._cleanupCallbacks = [() => {
|
|
499
487
|
for (const node of portalNodes) {
|
|
500
488
|
disposeTree(node);
|
|
@@ -505,525 +493,57 @@ function createPortalDOM(vnode, parent) {
|
|
|
505
493
|
return placeholder;
|
|
506
494
|
}
|
|
507
495
|
|
|
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];
|
|
532
|
-
|
|
533
|
-
if (i >= newVNodes.length) {
|
|
534
|
-
// Remove extra old nodes
|
|
535
|
-
if (oldNode && oldNode.parentNode) {
|
|
536
|
-
disposeTree(oldNode);
|
|
537
|
-
oldNode.parentNode.removeChild(oldNode);
|
|
538
|
-
}
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (i >= oldNodes.length) {
|
|
543
|
-
// Append new nodes
|
|
544
|
-
const node = createDOM(newVNode, parent);
|
|
545
|
-
if (node) {
|
|
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
|
-
}
|
|
496
|
+
// --- Create Element from VNode ---
|
|
497
|
+
// For h()-based VNodes with string tags
|
|
834
498
|
|
|
835
|
-
|
|
836
|
-
|
|
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
|
-
}
|
|
499
|
+
function createElementFromVNode(vnode, parent, isSvg) {
|
|
500
|
+
const { tag, props, children } = vnode;
|
|
880
501
|
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
}
|
|
502
|
+
const svgContext = isSvg || SVG_ELEMENTS.has(tag);
|
|
503
|
+
const el = svgContext
|
|
504
|
+
? document.createElementNS(SVG_NS, tag)
|
|
505
|
+
: document.createElement(tag);
|
|
893
506
|
|
|
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;
|
|
507
|
+
// Apply props
|
|
508
|
+
if (props) {
|
|
509
|
+
applyProps(el, props, {}, svgContext);
|
|
916
510
|
}
|
|
917
511
|
|
|
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;
|
|
512
|
+
// Append children
|
|
513
|
+
for (const child of children) {
|
|
514
|
+
const node = createDOM(child, el, svgContext && tag !== 'foreignObject');
|
|
515
|
+
if (node) el.appendChild(node);
|
|
943
516
|
}
|
|
944
517
|
|
|
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
|
-
}
|
|
518
|
+
el._vnode = vnode;
|
|
519
|
+
return el;
|
|
985
520
|
}
|
|
986
521
|
|
|
987
|
-
// --- Prop
|
|
988
|
-
// Only
|
|
522
|
+
// --- Prop Application ---
|
|
523
|
+
// Only applied once for fine-grained (no diffing). Reactive props use effects.
|
|
989
524
|
|
|
990
525
|
function applyProps(el, newProps, oldProps, isSvg) {
|
|
991
526
|
newProps = newProps || {};
|
|
992
527
|
oldProps = oldProps || {};
|
|
993
528
|
|
|
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
529
|
for (const key in newProps) {
|
|
1004
|
-
if (key === 'key' || key === '
|
|
1005
|
-
|
|
1006
|
-
|
|
530
|
+
if (key === 'key' || key === 'children') continue;
|
|
531
|
+
|
|
532
|
+
// Handle ref
|
|
533
|
+
if (key === 'ref') {
|
|
534
|
+
if (typeof newProps.ref === 'function') newProps.ref(el);
|
|
535
|
+
else if (newProps.ref) newProps.ref.current = el;
|
|
536
|
+
continue;
|
|
1007
537
|
}
|
|
1008
|
-
}
|
|
1009
538
|
|
|
1010
|
-
|
|
1011
|
-
if (newProps.ref && newProps.ref !== oldProps.ref) {
|
|
1012
|
-
if (typeof newProps.ref === 'function') newProps.ref(el);
|
|
1013
|
-
else newProps.ref.current = el;
|
|
539
|
+
setProp(el, key, newProps[key], isSvg);
|
|
1014
540
|
}
|
|
1015
541
|
}
|
|
1016
542
|
|
|
1017
543
|
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.
|
|
544
|
+
// Reactive function props — wrap in effect for fine-grained updates
|
|
1023
545
|
if (typeof value === 'function' && !(key.startsWith('on') && key.length > 2) && key !== 'ref') {
|
|
1024
|
-
// Store dispose functions on the element for cleanup
|
|
1025
546
|
if (!el._propEffects) el._propEffects = {};
|
|
1026
|
-
// Dispose previous effect for this prop if re-applying
|
|
1027
547
|
if (el._propEffects[key]) {
|
|
1028
548
|
try { el._propEffects[key](); } catch (e) { /* already disposed */ }
|
|
1029
549
|
}
|
|
@@ -1034,38 +554,27 @@ function setProp(el, key, value, isSvg) {
|
|
|
1034
554
|
return;
|
|
1035
555
|
}
|
|
1036
556
|
|
|
1037
|
-
// Event handlers
|
|
1038
|
-
// Wrap in untrack so signal reads in handlers don't create subscriptions
|
|
557
|
+
// Event handlers
|
|
1039
558
|
if (key.startsWith('on') && key.length > 2) {
|
|
1040
559
|
let eventName = key.slice(2);
|
|
1041
|
-
// React-style capture phase: onClickCapture → click in capture phase
|
|
1042
560
|
let useCapture = false;
|
|
1043
561
|
if (eventName.endsWith('Capture')) {
|
|
1044
562
|
eventName = eventName.slice(0, -7);
|
|
1045
563
|
useCapture = true;
|
|
1046
564
|
}
|
|
1047
565
|
const event = eventName.toLowerCase();
|
|
1048
|
-
// Use a combined key for storage so capture/bubble don't conflict
|
|
1049
566
|
const storageKey = useCapture ? event + '_capture' : event;
|
|
1050
|
-
// Store handler for removal
|
|
1051
567
|
const old = el._events?.[storageKey];
|
|
1052
|
-
// Skip re-wrapping if same handler function
|
|
1053
568
|
if (old && old._original === value) return;
|
|
1054
569
|
if (old) el.removeEventListener(event, old, useCapture);
|
|
1055
|
-
// If handler is null/undefined, just remove the old one and bail
|
|
1056
570
|
if (value == null) return;
|
|
1057
571
|
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
572
|
const wrappedHandler = (e) => {
|
|
1063
573
|
if (!e.nativeEvent) e.nativeEvent = e;
|
|
1064
574
|
return untrack(() => value(e));
|
|
1065
575
|
};
|
|
1066
576
|
wrappedHandler._original = value;
|
|
1067
577
|
el._events[storageKey] = wrappedHandler;
|
|
1068
|
-
// Check for _eventOpts (once/capture/passive from compiler)
|
|
1069
578
|
const eventOpts = value._eventOpts;
|
|
1070
579
|
el.addEventListener(event, wrappedHandler, eventOpts || useCapture || undefined);
|
|
1071
580
|
return;
|
|
@@ -1081,13 +590,12 @@ function setProp(el, key, value, isSvg) {
|
|
|
1081
590
|
return;
|
|
1082
591
|
}
|
|
1083
592
|
|
|
1084
|
-
// Style
|
|
593
|
+
// Style
|
|
1085
594
|
if (key === 'style') {
|
|
1086
595
|
if (typeof value === 'string') {
|
|
1087
596
|
el.style.cssText = value;
|
|
1088
597
|
el._prevStyle = null;
|
|
1089
598
|
} else if (typeof value === 'object') {
|
|
1090
|
-
// Remove old style properties not in new style
|
|
1091
599
|
const oldStyle = el._prevStyle || {};
|
|
1092
600
|
for (const prop in oldStyle) {
|
|
1093
601
|
if (!(prop in value)) el.style[prop] = '';
|
|
@@ -1106,12 +614,20 @@ function setProp(el, key, value, isSvg) {
|
|
|
1106
614
|
return;
|
|
1107
615
|
}
|
|
1108
616
|
|
|
1109
|
-
// innerHTML
|
|
617
|
+
// innerHTML — require { __html: ... } wrapper to prevent XSS
|
|
1110
618
|
if (key === 'innerHTML') {
|
|
619
|
+
if (value == null) return; // null/undefined — do nothing
|
|
1111
620
|
if (value && typeof value === 'object' && '__html' in value) {
|
|
1112
621
|
el.innerHTML = value.__html ?? '';
|
|
1113
622
|
} else {
|
|
1114
|
-
|
|
623
|
+
if (__DEV__) {
|
|
624
|
+
console.warn(
|
|
625
|
+
'[what] innerHTML received a raw string. This is a security risk (XSS). ' +
|
|
626
|
+
'Use innerHTML={{ __html: trustedString }} or dangerouslySetInnerHTML={{ __html: trustedString }} instead.'
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
// Refuse to set raw string innerHTML — prevent XSS
|
|
630
|
+
return;
|
|
1115
631
|
}
|
|
1116
632
|
return;
|
|
1117
633
|
}
|
|
@@ -1123,13 +639,13 @@ function setProp(el, key, value, isSvg) {
|
|
|
1123
639
|
return;
|
|
1124
640
|
}
|
|
1125
641
|
|
|
1126
|
-
// data-* and aria-*
|
|
642
|
+
// data-* and aria-*
|
|
1127
643
|
if (key.startsWith('data-') || key.startsWith('aria-')) {
|
|
1128
644
|
el.setAttribute(key, value);
|
|
1129
645
|
return;
|
|
1130
646
|
}
|
|
1131
647
|
|
|
1132
|
-
// SVG
|
|
648
|
+
// SVG
|
|
1133
649
|
if (isSvg) {
|
|
1134
650
|
if (value === false || value == null) {
|
|
1135
651
|
el.removeAttribute(key);
|
|
@@ -1139,46 +655,10 @@ function setProp(el, key, value, isSvg) {
|
|
|
1139
655
|
return;
|
|
1140
656
|
}
|
|
1141
657
|
|
|
1142
|
-
// Default:
|
|
658
|
+
// Default: property if exists, otherwise attribute
|
|
1143
659
|
if (key in el) {
|
|
1144
660
|
el[key] = value;
|
|
1145
661
|
} else {
|
|
1146
662
|
el.setAttribute(key, value);
|
|
1147
663
|
}
|
|
1148
664
|
}
|
|
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
|
-
}
|