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/dist/index.js +114 -50
- package/dist/index.js.map +2 -2
- package/dist/index.min.js +6 -6
- package/dist/index.min.js.map +3 -3
- package/dist/render.js +97 -49
- package/dist/render.js.map +2 -2
- package/dist/render.min.js +1 -1
- package/dist/render.min.js.map +3 -3
- package/dist/testing.js +100 -47
- package/dist/testing.js.map +2 -2
- package/dist/testing.min.js +1 -1
- package/dist/testing.min.js.map +3 -3
- package/package.json +1 -1
- package/src/dom.js +60 -20
- package/src/reactive.js +78 -40
- package/src/render.js +17 -0
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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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,
|
|
408
|
-
if (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
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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,
|
|
448
|
-
if (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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
551
|
-
|
|
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)) {
|