what-core 0.6.0 → 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
@@ -372,26 +372,38 @@ function createErrorBoundary(vnode, parent) {
372
372
  const { errorState, handleError, fallback, reset } = vnode.props;
373
373
  const children = vnode.children;
374
374
 
375
- const wrapper = document.createElement('span');
376
- 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');
377
379
 
378
380
  const boundaryCtx = {
379
381
  hooks: [], hookIndex: 0, effects: [], cleanups: [],
380
382
  mounted: false, disposed: false,
381
383
  _parentCtx: componentStack[componentStack.length - 1] || null,
382
384
  _errorBoundary: handleError,
385
+ _startComment: startComment,
386
+ _endComment: endComment,
383
387
  };
384
- 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);
385
394
 
386
395
  const dispose = effect(() => {
387
396
  const error = errorState();
388
397
 
389
398
  componentStack.push(boundaryCtx);
390
399
 
391
- // Remove old content
392
- while (wrapper.firstChild) {
393
- disposeTree(wrapper.firstChild);
394
- wrapper.removeChild(wrapper.firstChild);
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
+ }
395
407
  }
396
408
 
397
409
  let vnodes;
@@ -404,15 +416,23 @@ function createErrorBoundary(vnode, parent) {
404
416
  vnodes = Array.isArray(vnodes) ? vnodes : [vnodes];
405
417
 
406
418
  for (const v of vnodes) {
407
- const node = createDOM(v, wrapper);
408
- if (node) wrapper.appendChild(node);
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
+ }
428
+ }
409
429
  }
410
430
 
411
431
  componentStack.pop();
412
432
  });
413
433
 
414
434
  boundaryCtx.effects.push(dispose);
415
- return wrapper;
435
+ return container;
416
436
  }
417
437
 
418
438
  // Suspense boundary component handler
@@ -420,15 +440,24 @@ function createSuspenseBoundary(vnode, parent) {
420
440
  const { boundary, fallback, loading } = vnode.props;
421
441
  const children = vnode.children;
422
442
 
423
- const wrapper = document.createElement('span');
424
- 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');
425
447
 
426
448
  const boundaryCtx = {
427
449
  hooks: [], hookIndex: 0, effects: [], cleanups: [],
428
450
  mounted: false, disposed: false,
429
451
  _parentCtx: componentStack[componentStack.length - 1] || null,
452
+ _startComment: startComment,
453
+ _endComment: endComment,
430
454
  };
431
- 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);
432
461
 
433
462
  const dispose = effect(() => {
434
463
  const isLoading = loading();
@@ -437,22 +466,33 @@ function createSuspenseBoundary(vnode, parent) {
437
466
 
438
467
  componentStack.push(boundaryCtx);
439
468
 
440
- // Remove old content
441
- while (wrapper.firstChild) {
442
- disposeTree(wrapper.firstChild);
443
- wrapper.removeChild(wrapper.firstChild);
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
+ }
444
476
  }
445
477
 
446
478
  for (const v of normalized) {
447
- const node = createDOM(v, wrapper);
448
- if (node) wrapper.appendChild(node);
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
+ }
488
+ }
449
489
  }
450
490
 
451
491
  componentStack.pop();
452
492
  });
453
493
 
454
494
  boundaryCtx.effects.push(dispose);
455
- return wrapper;
495
+ return container;
456
496
  }
457
497
 
458
498
  // Portal component handler
package/src/reactive.js CHANGED
@@ -504,51 +504,65 @@ function scheduleMicrotask() {
504
504
  }
505
505
  }
506
506
 
507
+ let isFlushing = false;
508
+
507
509
  function flush() {
508
- let iterations = 0;
509
- while (pendingEffects.length > 0 && iterations < 25) {
510
- const batch = pendingEffects;
511
- pendingEffects = [];
512
-
513
- // Topological sort: execute effects in level order (lowest first).
514
- // Fast paths:
515
- // 1. Single effect — no sort needed (most common case for microtask flush)
516
- // 2. Already sorted skip sort (common when effects added in level order)
517
- // 3. Multiple effects at different levels — sort required
518
- if (batch.length > 1 && pendingNeedSort) {
519
- batch.sort((a, b) => a._level - b._level);
520
- }
521
- pendingNeedSort = false;
522
-
523
- for (let i = 0; i < batch.length; i++) {
524
- const e = batch[i];
525
- e._pending = false;
526
- if (!e.disposed && !e._onNotify) {
527
- const prevDepsLen = e.deps.length;
528
- _runEffect(e);
529
- // Update level only if deps changed (graph structure change)
530
- if (!e._computed && e.deps.length !== prevDepsLen) {
531
- _updateLevel(e);
510
+ // Re-entrancy guard: if flush() is called during an active flush (e.g., via
511
+ // flushSync() inside a component render or effect), skip to prevent infinite
512
+ // recursion. Pending effects will be picked up by the outer flush's while-loop.
513
+ if (isFlushing) return;
514
+ isFlushing = true;
515
+
516
+ try {
517
+ let iterations = 0;
518
+ while (pendingEffects.length > 0 && iterations < 25) {
519
+ const batch = pendingEffects;
520
+ pendingEffects = [];
521
+
522
+ // Topological sort: execute effects in level order (lowest first).
523
+ // Fast paths:
524
+ // 1. Single effect — no sort needed (most common case for microtask flush)
525
+ // 2. Already sorted — skip sort (common when effects added in level order)
526
+ // 3. Multiple effects at different levels — sort required
527
+ if (batch.length > 1 && pendingNeedSort) {
528
+ batch.sort((a, b) => a._level - b._level);
529
+ }
530
+ pendingNeedSort = false;
531
+
532
+ for (let i = 0; i < batch.length; i++) {
533
+ const e = batch[i];
534
+ e._pending = false;
535
+ if (!e.disposed && !e._onNotify) {
536
+ const prevDepsLen = e.deps.length;
537
+ _runEffect(e);
538
+ // Update level only if deps changed (graph structure change)
539
+ if (!e._computed && e.deps.length !== prevDepsLen) {
540
+ _updateLevel(e);
541
+ }
532
542
  }
533
543
  }
544
+ iterations++;
534
545
  }
535
- iterations++;
536
- }
537
- if (iterations >= 25) {
538
- if (__DEV__) {
539
- const remaining = pendingEffects.slice(0, 3);
540
- const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
541
- console.warn(
542
- `[what] Possible infinite effect loop detected (25 iterations). ` +
543
- `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
544
- `Use untrack() to read signals without subscribing. ` +
545
- `Looping effects: ${effectNames.join(', ')}`
546
- );
547
- } else {
548
- console.warn('[what] Possible infinite effect loop detected');
546
+ if (iterations >= 25) {
547
+ // Clear pending effects to prevent further damage
548
+ for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
549
+ pendingEffects.length = 0;
550
+
551
+ if (__DEV__) {
552
+ const remaining = pendingEffects.slice(0, 3);
553
+ const effectNames = remaining.map(e => e.fn?.name || e.fn?.toString().slice(0, 60) || '(anonymous)');
554
+ console.warn(
555
+ `[what] Possible infinite effect loop detected (25 iterations). ` +
556
+ `Likely cause: an effect writes to a signal it also reads, creating a cycle. ` +
557
+ `Use untrack() to read signals without subscribing. ` +
558
+ `Looping effects: ${effectNames.join(', ')}`
559
+ );
560
+ } else {
561
+ console.warn('[what] Possible infinite effect loop detected');
562
+ }
549
563
  }
550
- for (let i = 0; i < pendingEffects.length; i++) pendingEffects[i]._pending = false;
551
- pendingEffects.length = 0;
564
+ } finally {
565
+ isFlushing = false;
552
566
  }
553
567
  }
554
568
 
@@ -613,7 +627,31 @@ export function memo(fn) {
613
627
 
614
628
  // --- flushSync ---
615
629
  // Force all pending effects to run synchronously. Use sparingly.
630
+ // Calling during render or effect execution is a no-op (prevents infinite loops).
616
631
  export function flushSync() {
632
+ if (isFlushing) {
633
+ // Re-entrant call — silently skip (Solid approach).
634
+ // This prevents infinite loops when flushSync() is called during component
635
+ // render or effect execution. Pending effects will be picked up by the
636
+ // outer flush's while-loop.
637
+ if (__DEV__) {
638
+ console.warn(
639
+ '[what] flushSync() called during an active flush (e.g., inside a component render or effect). ' +
640
+ 'This is a no-op to prevent infinite loops. Move flushSync() to an event handler or onMount callback.'
641
+ );
642
+ }
643
+ return;
644
+ }
645
+ if (currentEffect) {
646
+ // Called inside an effect/render — skip with warning
647
+ if (__DEV__) {
648
+ console.warn(
649
+ '[what] flushSync() called during effect execution. ' +
650
+ 'This is a no-op to prevent infinite loops. Move flushSync() to an event handler or onMount callback.'
651
+ );
652
+ }
653
+ return;
654
+ }
617
655
  microtaskScheduled = false;
618
656
  flush();
619
657
  }
package/src/render.js CHANGED
@@ -225,6 +225,16 @@ function sameNodeArray(a, b) {
225
225
  }
226
226
 
227
227
  function reconcileInsert(parent, value, current, marker) {
228
+ // Guard: parent must be a node that supports child operations.
229
+ // This catches cases where a stale DOM reference (e.g., a comment node from
230
+ // shifted childNodes indices) is mistakenly passed as the parent.
231
+ if (!parent || typeof parent.insertBefore !== 'function') {
232
+ if (__DEV__) {
233
+ console.warn('[what] reconcileInsert called with invalid parent:', parent);
234
+ }
235
+ return current;
236
+ }
237
+
228
238
  const targetMarker = marker || null;
229
239
 
230
240
  if (value == null || typeof value === 'boolean') {
@@ -848,6 +858,13 @@ export function spread(el, props) {
848
858
  }
849
859
 
850
860
  export function setProp(el, key, value) {
861
+ // Ref handling — assign element to ref object/callback (defense in depth)
862
+ if (key === 'ref') {
863
+ if (typeof value === 'function') value(el);
864
+ else if (value && typeof value === 'object') value.current = el;
865
+ return;
866
+ }
867
+
851
868
  // Sanitize URL attributes — reject dangerous protocols
852
869
  if (URL_ATTRS.has(key) || URL_ATTRS.has(key.toLowerCase())) {
853
870
  if (!isSafeUrl(value)) {