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/src/dom.js CHANGED
@@ -1,52 +1,11 @@
1
- // What Framework - DOM Reconciler
2
- // Surgical DOM updates. Diff props, diff children, patch only what changed.
3
- // Components use <what-c> wrapper elements (display:contents) for clean reconciliation.
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 useEffect cleanup functions in reverse order (last effect first, matching React)
82
- for (let i = ctx.hooks.length - 1; i >= 0; i--) {
83
- const hook = ctx.hooks[i];
84
- if (hook && typeof hook === 'object' && 'cleanup' in hook && hook.cleanup) {
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 onCleanup callbacks in reverse order (last registered first)
90
- if (ctx._cleanupCallbacks) {
91
- for (let i = ctx._cleanupCallbacks.length - 1; i >= 0; i--) {
92
- try { ctx._cleanupCallbacks[i](); } catch (e) { console.error('[what] onCleanup error:', e); }
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
- // Dispose reactive effects
97
- for (const dispose of ctx.effects) {
98
- try { dispose(); } catch (e) { /* effect already disposed */ }
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 (compiler-first components can return real nodes)
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 — creates a wrapper that updates fine-grained
162
- // Handles both primitives ({() => count()}) and vnodes ({() => items().map(...)})
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 wrapper = document.createElement('what-c');
165
- let mounted = false;
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
- if (!mounted) {
173
- mounted = true;
174
- for (const v of vnodes) {
175
- const node = createDOM(v, wrapper, parent?._isSvg);
176
- if (node) wrapper.appendChild(node);
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
- wrapper._dispose = dispose;
183
- return wrapper;
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 (fragment)
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
- // Unknown object child fallback
197
- if (!isVNode(vnode)) {
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
- // Detect SVG context: either we're already in SVG, or this tag is an SVG element
212
- const svgContext = isSvg || vnode.tag === 'svg' || SVG_ELEMENTS.has(vnode.tag);
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
- // Store vnode on element for diffing
233
- el._vnode = vnode;
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 — ES6 classes can't be called without `new`.
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') { // Now also handled in createDOM directly
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, // Store for identity check in patchNode
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
- // Wrapper element: <what-c display:contents> for HTML, <g> for SVG
302
- // Note: <what-c> custom element sets display:contents in its constructor
303
- let wrapper;
304
- if (isSvg) {
305
- wrapper = document.createElementNS(SVG_NS, 'g');
306
- } else {
307
- wrapper = document.createElement('what-c');
308
- }
309
- wrapper._componentCtx = ctx;
310
- wrapper._isSvg = !!isSvg;
311
- ctx._wrapper = wrapper;
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
- // Reactive render: re-renders when signals used inside change
324
- const dispose = effect(() => {
325
- if (ctx.disposed) return;
326
- ctx.hookIndex = 0;
327
-
328
- componentStack.push(ctx);
329
-
330
- let result;
331
- try {
332
- result = Component(propsSignal());
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;
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
- const vnodes = Array.isArray(result) ? result : [result];
327
+ // Component runs ONCE not inside an effect
328
+ componentStack.push(ctx);
347
329
 
348
- if (!ctx.mounted) {
349
- // Initial mount
350
- ctx.mounted = true;
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
- // Run onMount callbacks after DOM is ready
353
- if (ctx._mountCallbacks) {
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
- for (const v of vnodes) {
363
- const node = createDOM(v, wrapper, isSvg);
364
- if (node) wrapper.appendChild(node);
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
- } else {
367
- // Update: reconcile children inside wrapper
368
- reconcileChildren(wrapper, vnodes);
369
- }
355
+ });
356
+ }
370
357
 
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
- ctx.effects.push(dispose);
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
- const wrapper = document.createElement('what-c');
385
- wrapper.style.display = 'contents';
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
- wrapper._componentCtx = boundaryCtx;
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
- if (wrapper.childNodes.length === 0) {
412
- for (const v of vnodes) {
413
- const node = createDOM(v, wrapper);
414
- if (node) wrapper.appendChild(node);
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 wrapper;
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
- const wrapper = document.createElement('what-c');
433
- wrapper.style.display = 'contents';
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
- wrapper._componentCtx = boundaryCtx;
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
- if (wrapper.childNodes.length === 0) {
451
- for (const v of normalized) {
452
- const node = createDOM(v, wrapper);
453
- if (node) wrapper.appendChild(node);
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 wrapper;
495
+ return container;
464
496
  }
465
497
 
466
- // Portal component handler — renders children into a different DOM container
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
- // --- Reconciliation ---
509
- // Diff old DOM nodes against new VNodes, patch in place.
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
- 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
- }
539
+ function createElementFromVNode(vnode, parent, isSvg) {
540
+ const { tag, props, children } = vnode;
541
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
- }
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
- // Component
895
- if (typeof vnode.tag === 'function') {
896
- // Check if old node is a component wrapper for the same component
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
- // Element: same tag? Patch props + children
919
- if (domNode.nodeType === 1 && domNode.tagName.toLowerCase() === vnode.tag) {
920
- const oldProps = domNode._vnode?.props || {};
921
- const nextProps = vnode.props || {};
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
- // Different tag: replace entirely
946
- const newNode = createDOM(vnode, parent);
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 Diffing ---
988
- // Only touch DOM for props that actually changed.
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 === 'ref' || key === 'children') continue;
1005
- if (newProps[key] !== oldProps[key]) {
1006
- setProp(el, key, newProps[key], isSvg);
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
- // Handle ref
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() for fine-grained updates.
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: onClick -> click, onFocusCapture -> focus (capture phase)
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 object — track previous style to remove stale properties
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 convenience alias
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
- el.innerHTML = value ?? '';
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-* as attributes
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: always use setAttribute (SVG properties don't work as DOM properties)
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: set as property if it exists, otherwise attribute
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
- }