From 26786e92985842a6d563c67049a43bc5857bf792 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:07:07 +0530 Subject: [PATCH 1/7] fix: skip controlled each fast path while another batch is pending (#18625) Fixes #18610 ### Problem In async mode, a controlled keyed `{#each}` throws `TypeError: Cannot read properties of undefined (reading 'e')` when its collection becomes empty while an earlier batch is still pending on the same block. The fast path in `pause_effects` cleared `state.items` and then called `destroy_effects`, which walks pending batch keys and reads `state.items.get(key).e`. Those EachItems are still needed for the pending batch (preserved offscreen), so clearing the map makes the dereference throw and aborts the commit mid-flight. ### Fix Only take the controlled-each fast path when `state.pending.size === 0`, so pending batches keep their items until they commit or discard. --- .changeset/async-each-controlled-pending.md | 5 ++ .../src/internal/client/dom/blocks/each.js | 8 ++- .../_config.js | 50 +++++++++++++++++ .../main.svelte | 54 +++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 .changeset/async-each-controlled-pending.md create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js create mode 100644 packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte diff --git a/.changeset/async-each-controlled-pending.md b/.changeset/async-each-controlled-pending.md new file mode 100644 index 0000000000..c37f914d13 --- /dev/null +++ b/.changeset/async-each-controlled-pending.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: skip controlled each fast path while another batch is pending diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 2df1d6ffa1..9a2504f887 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -103,8 +103,12 @@ function pause_effects(state, to_destroy, controlled_anchor) { if (remaining === 0) { // If we're in a controlled each block (i.e. the block is the only child of an // element), and we are removing all items, _and_ there are no out transitions, - // we can use the fast path — emptying the element and replacing the anchor - var fast_path = transitions.length === 0 && controlled_anchor !== null; + // we can use the fast path — emptying the element and replacing the anchor. + // Skip the fast path when another batch is still pending on this each block: + // that batch's keys still reference EachItems in `state.items`, which + // `destroy_effects` needs to preserve offscreen (see #18610). + var fast_path = + transitions.length === 0 && controlled_anchor !== null && state.pending.size === 0; if (fast_path) { var anchor = /** @type {Element} */ (controlled_anchor); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js new file mode 100644 index 0000000000..29d374deec --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/_config.js @@ -0,0 +1,50 @@ +import { tick } from 'svelte'; +import { test } from '../../test'; + +// Regression for #18610: emptying a controlled keyed {#each} while another +// batch is still pending must not take the fast path that clears state.items +// before destroy_effects walks pending keys. +export default test({ + mode: ['client'], + + async test({ assert, target }) { + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

A0/B0

+
12
+ ` + ); + + const [startA, startB, settleB] = target.querySelectorAll('button'); + + // Batch A: add key 9, then block forever on gate A. + startA.click(); + await tick(); + + // Batch B: empty the collection, then block on gate B. + startB.click(); + await tick(); + + // Settle B first so B commits while A is still pending. + // Without the fix this throws reading `.e` of undefined and leaves a/b stuck. + settleB.click(); + await tick(); + + assert.htmlEqual( + target.innerHTML, + ` + + + +

A0/B1

+
+ ` + ); + } +}); diff --git a/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte new file mode 100644 index 0000000000..68c3937c25 --- /dev/null +++ b/packages/svelte/tests/runtime-runes/samples/async-each-controlled-empty-pending/main.svelte @@ -0,0 +1,54 @@ + + + + + + +

{a}/{b}

+ + +
+ {#each items as item (item)} + {item} + {/each} +
From 3ed9db4ba78f61792b875b7651755a9c0a018037 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:40:28 +0200 Subject: [PATCH 2/7] fix: don't duplicate comments in attributes (#18636) An attribute with a comment would be printed again atop the following attribute because we didn't take that case into account. --- .changeset/modern-otters-pick.md | 5 +++++ packages/svelte/src/compiler/print/index.js | 18 ++++++++++++------ .../comment-inside-attribute/input.svelte | 7 +++++++ .../comment-inside-attribute/output.svelte | 7 +++++++ 4 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 .changeset/modern-otters-pick.md create mode 100644 packages/svelte/tests/print/samples/comment-inside-attribute/input.svelte create mode 100644 packages/svelte/tests/print/samples/comment-inside-attribute/output.svelte diff --git a/.changeset/modern-otters-pick.md b/.changeset/modern-otters-pick.md new file mode 100644 index 0000000000..de5803e19f --- /dev/null +++ b/.changeset/modern-otters-pick.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: don't duplicate comments in attributes diff --git a/packages/svelte/src/compiler/print/index.js b/packages/svelte/src/compiler/print/index.js index a5fbc96fc8..fe76f31ad3 100644 --- a/packages/svelte/src/compiler/print/index.js +++ b/packages/svelte/src/compiler/print/index.js @@ -82,6 +82,7 @@ function attributes(node, attributes, context, comments) { } const separator = context.new(); + let previous_attribute_end = node.start; const children = attributes.map((attribute) => { const child_context = context.new(); @@ -90,12 +91,16 @@ function attributes(node, attributes, context, comments) { const comment = comments[comment_index]; if (comment.start < attribute.start) { - if (comment.type === 'Line') { - child_context.write('//' + comment.value); - child_context.newline(); - } else { - child_context.write('/*' + comment.value + '*/'); // TODO match indentation? - child_context.append(separator); + // Inside a previous attribute's value can be comments which don't + // advance comment_index, therefore this additional check + if (comment.start >= previous_attribute_end) { + if (comment.type === 'Line') { + child_context.write('//' + comment.value); + child_context.newline(); + } else { + child_context.write('/*' + comment.value + '*/'); // TODO match indentation? + child_context.append(separator); + } } comment_index += 1; @@ -105,6 +110,7 @@ function attributes(node, attributes, context, comments) { } child_context.visit(attribute); + previous_attribute_end = attribute.end; length += child_context.measure() + 1; diff --git a/packages/svelte/tests/print/samples/comment-inside-attribute/input.svelte b/packages/svelte/tests/print/samples/comment-inside-attribute/input.svelte new file mode 100644 index 0000000000..4575abf75a --- /dev/null +++ b/packages/svelte/tests/print/samples/comment-inside-attribute/input.svelte @@ -0,0 +1,7 @@ +