From d7a8e3d1300fbb802b84a6ebf0f3f71ed734a556 Mon Sep 17 00:00:00 2001 From: 7nik Date: Wed, 4 Feb 2026 21:51:50 +0200 Subject: [PATCH] fix: emit `each_key_duplicate` error in production (#16724) * fix: emit `each_key_duplicate` error in production * fix: preserve key * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * Update packages/svelte/src/internal/client/dom/blocks/each.js Co-authored-by: Rich Harris * fix: ensure keys are validated * fix silly test name * fix: cover other case of duplicate keys * emit error on hydration * ensure the error is handled * drop useless tests * unused * finish merge * add lost check back * chore: bump playwright (#17565) * chore: bump playwright * maybe this will help somehow? * err whatever * fix * chore: allow testing in production env 2 (#17590) * Revert "chore: allow testing in production env (#16840)" This reverts commit ffd65e90febc29feaca48e142126a4087fcaca9f. * new approach * fix: handle renderer.run rejections (#17591) * fix: handle renderer run rejections * add test * changeset * simplify * explanatory comment --------- Co-authored-by: Antonio Bennett Co-authored-by: Rich Harris * fix: only create async functions in SSR output when necessary (#17593) * fix: only create async functions in SSR output when necessary * actually... * simplify generated code a bit more * simplify * fix: merge consecutive text nodes during hydration for large text content (#17587) * fix: merge consecutive text nodes during hydration for large text content Fixes #17582 Browsers automatically split text nodes exceeding 65536 characters into multiple consecutive text nodes during HTML parsing. This causes hydration mismatches when Svelte expects a single text node. The fix merges consecutive text nodes during hydration by: - Detecting when the current node is a text node - Finding all consecutive text node siblings - Merging their content into the first text node - Removing the extra text nodes This restores correct hydration behavior for large text content. * add test, fix * fix * fix * changeset --------- Co-authored-by: Miner Co-authored-by: Rich Harris * Version Packages (#17585) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Revert "drop useless tests" This reverts commit 65f77ef8409d6e8c91163f36853d36b05ec01ae8. * update tests * fix test * we don't need to expose this function any more * figured it out... we cant have errors during reconcile * simplify * tweak * unused * revert no-longer-needed change * unused --------- Co-authored-by: Rich Harris Co-authored-by: Antonio Bennett <31296212+Antonio-Bennett@users.noreply.github.com> Co-authored-by: Antonio Bennett Co-authored-by: FORMI <239411042+Richman018@users.noreply.github.com> Co-authored-by: Miner Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/sharp-snakes-poke.md | 5 +++ .../3-transform/client/visitors/EachBlock.js | 4 -- .../src/internal/client/dom/blocks/each.js | 37 +++++++++++++++++++ packages/svelte/src/internal/client/index.js | 2 +- .../svelte/src/internal/client/validate.js | 34 ----------------- .../samples/keyed-each-unique-2/_config.js | 12 ++++++ .../samples/keyed-each-unique-2/main.svelte | 8 ++++ .../samples/keyed-each-unique-3/_config.js | 5 +++ .../samples/keyed-each-unique-3/main.svelte | 7 ++++ .../samples/keyed-each-unique/_config.js | 12 ++++++ .../samples/keyed-each-unique/main.svelte | 8 ++++ 11 files changed, 95 insertions(+), 39 deletions(-) create mode 100644 .changeset/sharp-snakes-poke.md create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js create mode 100644 packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte diff --git a/.changeset/sharp-snakes-poke.md b/.changeset/sharp-snakes-poke.md new file mode 100644 index 0000000000..7f7f8aa7b2 --- /dev/null +++ b/.changeset/sharp-snakes-poke.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: emit `each_key_duplicate` error in production diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js index 1dbc34fdc3..b2724fa90f 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/EachBlock.js @@ -338,10 +338,6 @@ export function EachBlock(node, context) { const statements = [add_svelte_meta(b.call('$.each', ...args), node, 'each')]; - if (dev && node.metadata.keyed) { - statements.unshift(b.stmt(b.call('$.validate_each_keys', thunk, key_function))); - } - if (node.metadata.expression.is_async()) { context.state.init.push( b.stmt( diff --git a/packages/svelte/src/internal/client/dom/blocks/each.js b/packages/svelte/src/internal/client/dom/blocks/each.js index 6eaeac0f38..25f7cf91eb 100644 --- a/packages/svelte/src/internal/client/dom/blocks/each.js +++ b/packages/svelte/src/internal/client/dom/blocks/each.js @@ -40,6 +40,7 @@ import { get } from '../../runtime.js'; import { DEV } from 'esm-env'; import { derived_safe_equal } from '../../reactivity/deriveds.js'; import { current_batch } from '../../reactivity/batch.js'; +import * as e from '../../errors.js'; // When making substantive changes to this file, validate them with the each block stress test: // https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b @@ -290,6 +291,15 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f } } + if (length > keys.size) { + if (DEV) { + validate_each_keys(array, get_key); + } else { + // in prod, the additional information isn't printed, so don't bother computing it + e.each_key_duplicate('', '', ''); + } + } + // remove excess nodes if (hydrating && length > 0) { set_hydrate_node(skip_nodes()); @@ -676,3 +686,30 @@ function link(state, prev, next) { next.prev = prev; } } + +/** + * @param {Array} array + * @param {(item: any, index: number) => string} key_fn + * @returns {void} + */ +function validate_each_keys(array, key_fn) { + const keys = new Map(); + const length = array.length; + + for (let i = 0; i < length; i++) { + const key = key_fn(array[i], i); + + if (keys.has(key)) { + const a = String(keys.get(key)); + const b = String(i); + + /** @type {string | null} */ + let k = String(key); + if (k.startsWith('[object ')) k = null; + + e.each_key_duplicate(a, b, k); + } + + keys.set(key, i); + } +} diff --git a/packages/svelte/src/internal/client/index.js b/packages/svelte/src/internal/client/index.js index 1d9f7dfff7..7fcaf77dc5 100644 --- a/packages/svelte/src/internal/client/index.js +++ b/packages/svelte/src/internal/client/index.js @@ -158,7 +158,7 @@ export { deep_read_state, active_effect } from './runtime.js'; -export { validate_binding, validate_each_keys } from './validate.js'; +export { validate_binding } from './validate.js'; export { raf } from './timing.js'; export { proxy } from './proxy.js'; export { create_custom_element } from './dom/elements/custom-element.js'; diff --git a/packages/svelte/src/internal/client/validate.js b/packages/svelte/src/internal/client/validate.js index 48a44db304..a169225f1e 100644 --- a/packages/svelte/src/internal/client/validate.js +++ b/packages/svelte/src/internal/client/validate.js @@ -1,45 +1,11 @@ /** @import { Blocker } from '#client' */ import { dev_current_component_function } from './context.js'; -import { is_array } from '../shared/utils.js'; -import * as e from './errors.js'; import { FILENAME } from '../../constants.js'; import { render_effect } from './reactivity/effects.js'; import * as w from './warnings.js'; import { capture_store_binding } from './reactivity/store.js'; import { run_after_blockers } from './reactivity/async.js'; -/** - * @param {() => any} collection - * @param {(item: any, index: number) => string} key_fn - * @returns {void} - */ -export function validate_each_keys(collection, key_fn) { - render_effect(() => { - const keys = new Map(); - const maybe_array = collection(); - const array = is_array(maybe_array) - ? maybe_array - : maybe_array == null - ? [] - : Array.from(maybe_array); - const length = array.length; - for (let i = 0; i < length; i++) { - const key = key_fn(array[i], i); - if (keys.has(key)) { - const a = String(keys.get(key)); - const b = String(i); - - /** @type {string | null} */ - let k = String(key); - if (k.startsWith('[object ')) k = null; - - e.each_key_duplicate(a, b, k); - } - keys.set(key, i); - } - }); -} - /** * @param {string} binding * @param {Blocker[]} blockers diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte new file mode 100644 index 0000000000..f8ba50d866 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-2/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js new file mode 100644 index 0000000000..7e1840200a --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/_config.js @@ -0,0 +1,5 @@ +import { test } from '../../test'; + +export default test({ + error: 'each_key_duplicate' +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte new file mode 100644 index 0000000000..a05781bcb9 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique-3/main.svelte @@ -0,0 +1,7 @@ + + +{#each data as d (d)} + {d} +{/each} diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js new file mode 100644 index 0000000000..bc945876ea --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/_config.js @@ -0,0 +1,12 @@ +import { flushSync } from 'svelte'; +import { test } from '../../test'; + +export default test({ + test({ assert, target }) { + let button = target.querySelector('button'); + + button?.click(); + + assert.throws(flushSync, 'https://svelte.dev/e/each_key_duplicate'); + } +}); diff --git a/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte new file mode 100644 index 0000000000..3d52179372 --- /dev/null +++ b/packages/svelte/tests/runtime-production/samples/keyed-each-unique/main.svelte @@ -0,0 +1,8 @@ + + + +{#each data as d (d)} + {d} +{/each}