Describe the bug
When a keyed {#each} block iterates over a $derived slice of a $state array, and the source array is replaced with new objects that have the different keys (but non-index keys), subsequently changing the slice start index from 0 to a positive value causes the page to freeze with 100% CPU usage — a synchronous infinite loop inside the template reconciliation.
Trigger conditions (all required):
- Keyed
{#each} over a $derived slice: items.slice(start, start + WINDOW)
- Non-index keys (e.g. UUIDs, string IDs). Using
(index) as key does NOT trigger the bug
- Source
$state array is replaced (new reference, different keys) — e.g. pull-to-refresh
- Slice start index changes from
0 to > 0 — e.g. virtual scroll
Does NOT freeze if:
- Step 3 (array replacement) is skipped — scrolling works fine on the original array
- Index is used as the key expression
- The
{#each} is wrapped in {#key arrayRef}...{/key}
Reproduction (real-world code)
The bug was discovered in a production virtual-scroll + pull-to-refresh app. The affected source files are:
Steps to reproduce in the real app:
- Open a feed page (list or waterfall tab)
- Pull-to-refresh (this replaces the
items/posts array with new objects having different IDs)
- Scroll down until
visibleRange.start > 0
- Page freezes — 100% CPU, completely unresponsive, requires force-kill
Workaround applied in the real code: wrap {#each} with {#key items} / {#key posts}.
Minimal reproduction
<!--
Svelte 5 bug reproduction:
Keyed {#each} over $derived slice freezes after source array replacement.
Steps:
1. Click "Refresh" (replaces the data array with different-keyed new objects)
2. Scroll down until visibleRange.start > 0
3. Page freezes with 100% CPU
If you skip step 1, scrolling works fine forever.
Workaround: wrap {#each} in {#key items}.
-->
<script lang="ts">
const TOTAL = 100;
const ITEM_HEIGHT = 60;
/**
* 生成带有 UUID key 的测试数据。
* 注意:必须使用非 index 的 key 才能触发 bug。用 index 做 key 无法复现。
*/
function generateItems(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: crypto.randomUUID(),
text: `Item ${i}`
}));
}
let items = $state(generateItems(TOTAL));
let scrollTop = $state(0);
let viewportHeight = $state(0);
/**
* 虚拟滚动可见范围:根据 scrollTop + viewportHeight 计算当前可见的 item 索引区间。
* viewportHeight 为 0 时(挂载前)渲染全量,防止首屏空白。
*/
const visibleRange = $derived.by(() => {
if (viewportHeight <= 0) return { start: 0, end: items.length - 1 };
const buffer = 2;
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - buffer);
const end = Math.min(
items.length - 1,
Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + buffer
);
return { start, end };
});
const visibleItems = $derived(items.slice(visibleRange.start, visibleRange.end + 1));
const topPad = $derived(visibleRange.start * ITEM_HEIGHT);
const bottomPad = $derived(
Math.max(0, (items.length - 1 - visibleRange.end) * ITEM_HEIGHT)
);
let scrollEl: HTMLElement;
let rafId: number | null = null;
/**
* 使用 RAF 合并 scroll 事件,模拟真实虚拟滚动的更新模式。
*/
function onScroll() {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
scrollTop = scrollEl.scrollTop;
});
}
/**
* 模拟 pull-to-refresh:用相同 key 的全新对象替换整个数组。
* 这正是 bug 的触发前提——替换后再滚动 start>0 时卡死。
*/
function refresh() {
items = generateItems(TOTAL);
}
</script>
<div style="padding: 16px; display: flex; flex-direction: column; gap: 8px; height: 100%;">
<div style="display: flex; gap: 8px; align-items: center; flex-shrink: 0;">
<button
onclick={refresh}
style="padding: 8px 16px; background: #ef4444; color: white; border: none; border-radius: 6px; cursor: pointer;"
>
1. Refresh (replace array, different keys)
</button>
<span style="font-size: 14px; color: #666;">
Then scroll down → page hangs when start > 0
</span>
</div>
<div
style="padding: 4px 8px; background: #f3f4f6; border-radius: 4px; font-family: monospace; font-size: 13px; flex-shrink: 0;"
>
range: [{visibleRange.start}, {visibleRange.end}] | visible: {visibleItems.length}
| total: {items.length}
</div>
<div
bind:this={scrollEl}
bind:clientHeight={viewportHeight}
onscroll={onScroll}
style="height: 70vh; overflow-y: scroll; border: 1px solid #d1d5db; border-radius: 8px;"
>
<div style="padding-top: {topPad}px; padding-bottom: {bottomPad}px;">
{#each visibleItems as item (item.id)}
<div
style="height: {ITEM_HEIGHT}px; display: flex; align-items: center; padding: 0 16px; border-bottom: 1px solid #e5e7eb; font-size: 14px;"
>
<span style="color: #9ca3af; font-family: monospace; margin-right: 12px;">
{item.id.slice(0, 8)}
</span>
{item.text}
</div>
{/each}
</div>
</div>
</div>
Steps:
- Click "Refresh" — replaces the array with new objects having different UUID keys
- Scroll down until
visibleRange.start > 0
- Page hangs with 100% CPU
Key observation: replacing (item.id) with a sequential index key like (i) or (String(index)) does NOT trigger the bug. The key must be a non-index value (UUID, string ID, etc.).
Debug investigation
Extensive instrumentation confirmed:
| Check |
Result |
$effect.pre flush counter |
Always 1 — no reactive flush loop |
queueMicrotask() probe after the trigger |
Never fires — main thread blocked synchronously |
setTimeout(0) probe after the trigger |
Never fires — confirms synchronous block |
| Location of the hang |
Inside Svelte's DOM reconciliation (template update phase), within a single reactive flush iteration |
Possible root cause
In packages/svelte/src/internal/client/dom/blocks/each.js, the reconcile function's EFFECT_OFFSCREEN branch appears to create a self-referencing cycle in the effect linked list.
When a newly created EFFECT_OFFSCREEN item is appended right after the last matched item in the linked list:
// In reconcile(), EFFECT_OFFSCREEN branch:
var next = prev ? prev.next : current;
// If prev.next === effect (item appended right after last match),
// then next === effect itself
// After unlinking:
if (effect.prev) effect.prev.next = effect.next;
if (effect.next) effect.next.prev = effect.prev;
// Re-link:
link(state, prev, effect); // prev.next = effect ✓
link(state, effect, next); // effect.next = effect ← SELF-CYCLE
This creates effect.next = effect. The cleanup loop then spins forever:
while (current !== null) {
// ...
current = skip_to_branch(current.next);
// current.next === current → infinite loop
}
The fact that index keys don't trigger the bug suggests the reconciler takes a different (correct) code path when keys happen to be sequential integers, avoiding the EFFECT_OFFSCREEN branch where the cycle is created.
System Info
Svelte: 5.43.8
SvelteKit: 2.48.5
Deno: 2.6.0
OS: macOS 15.5 / iOS Safari
Severity
blocking — page becomes completely unresponsive, requires force-kill
Describe the bug
When a keyed
{#each}block iterates over a$derivedslice of a$statearray, and the source array is replaced with new objects that have the different keys (but non-index keys), subsequently changing the slice start index from0to a positive value causes the page to freeze with 100% CPU usage — a synchronous infinite loop inside the template reconciliation.Trigger conditions (all required):
{#each}over a$derivedslice:items.slice(start, start + WINDOW)(index)as key does NOT trigger the bug$statearray is replaced (new reference, different keys) — e.g. pull-to-refresh0to> 0— e.g. virtual scrollDoes NOT freeze if:
{#each}is wrapped in{#key arrayRef}...{/key}Reproduction (real-world code)
The bug was discovered in a production virtual-scroll + pull-to-refresh app. The affected source files are:
feed-list.svelteat commita3f857fwaterfall-container.svelteat commita3f857fSteps to reproduce in the real app:
items/postsarray with new objects having different IDs)visibleRange.start > 0Workaround applied in the real code: wrap
{#each}with{#key items}/{#key posts}.Minimal reproduction
Steps:
visibleRange.start > 0Key observation: replacing
(item.id)with a sequential index key like(i)or(String(index))does NOT trigger the bug. The key must be a non-index value (UUID, string ID, etc.).Debug investigation
Extensive instrumentation confirmed:
$effect.preflush counterqueueMicrotask()probe after the triggersetTimeout(0)probe after the triggerPossible root cause
In
packages/svelte/src/internal/client/dom/blocks/each.js, thereconcilefunction'sEFFECT_OFFSCREENbranch appears to create a self-referencing cycle in the effect linked list.When a newly created
EFFECT_OFFSCREENitem is appended right after the last matched item in the linked list:This creates
effect.next = effect. The cleanup loop then spins forever:The fact that index keys don't trigger the bug suggests the reconciler takes a different (correct) code path when keys happen to be sequential integers, avoiding the
EFFECT_OFFSCREENbranch where the cycle is created.System Info
Severity
blocking — page becomes completely unresponsive, requires force-kill