Skip to content

Keyed {#each} over $derived slice freezes page (infinite loop in reconcile) after source array replacement #18083

@Gachikoi

Description

@Gachikoi

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):

  1. Keyed {#each} over a $derived slice: items.slice(start, start + WINDOW)
  2. Non-index keys (e.g. UUIDs, string IDs). Using (index) as key does NOT trigger the bug
  3. Source $state array is replaced (new reference, different keys) — e.g. pull-to-refresh
  4. 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:

  1. Open a feed page (list or waterfall tab)
  2. Pull-to-refresh (this replaces the items/posts array with new objects having different IDs)
  3. Scroll down until visibleRange.start > 0
  4. 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 &gt; 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}] &nbsp;|&nbsp; visible: {visibleItems.length}
		&nbsp;|&nbsp; 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:

  1. Click "Refresh" — replaces the array with new objects having different UUID keys
  2. Scroll down until visibleRange.start > 0
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    awaiting submitterneeds a reproduction, or clarification

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions