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/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
- }
323
+ return undefined;
324
+ },
325
+ });
341
326
 
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).
327
+ // Component runs ONCE not inside an effect
328
+ componentStack.push(ctx);
345
329
 
346
- const vnodes = Array.isArray(result) ? result : [result];
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
- if (!ctx.mounted) {
349
- // Initial mount
350
- ctx.mounted = true;
345
+ componentStack.pop();
346
+ ctx.mounted = true;
351
347
 
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
- });
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
- for (const v of vnodes) {
363
- const node = createDOM(v, wrapper, isSvg);
364
- if (node) wrapper.appendChild(node);
365
- }
366
- } else {
367
- // Update: reconcile children inside wrapper
368
- reconcileChildren(wrapper, vnodes);
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
- 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,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('what-c');
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
- if (wrapper.childNodes.length === 0) {
412
- for (const v of vnodes) {
413
- const node = createDOM(v, wrapper);
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('what-c');
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
- if (wrapper.childNodes.length === 0) {
451
- for (const v of normalized) {
452
- const node = createDOM(v, wrapper);
453
- if (node) wrapper.appendChild(node);
454
- }
455
- } else {
456
- reconcileChildren(wrapper, normalized);
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 — renders children into a different DOM container
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
- // --- 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];
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
- // 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
- }
499
+ function createElementFromVNode(vnode, parent, isSvg) {
500
+ const { tag, props, children } = vnode;
880
501
 
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
- }
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
- // 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;
507
+ // Apply props
508
+ if (props) {
509
+ applyProps(el, props, {}, svgContext);
916
510
  }
917
511
 
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;
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
- // 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
- }
518
+ el._vnode = vnode;
519
+ return el;
985
520
  }
986
521
 
987
- // --- Prop Diffing ---
988
- // Only touch DOM for props that actually changed.
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 === 'ref' || key === 'children') continue;
1005
- if (newProps[key] !== oldProps[key]) {
1006
- setProp(el, key, newProps[key], isSvg);
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
- // 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;
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() 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.
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: onClick -> click, onFocusCapture -> focus (capture phase)
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 object — track previous style to remove stale properties
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 convenience alias
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
- el.innerHTML = value ?? '';
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-* as attributes
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: always use setAttribute (SVG properties don't work as DOM properties)
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: set as property if it exists, otherwise attribute
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
- }