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 <rich.harris@vercel.com>

* Update packages/svelte/src/internal/client/dom/blocks/each.js

Co-authored-by: Rich Harris <rich.harris@vercel.com>

* 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 ffd65e90fe.

* new approach

* fix: handle renderer.run rejections (#17591)

* fix: handle renderer run rejections

* add test

* changeset

* simplify

* explanatory comment

---------

Co-authored-by: Antonio Bennett <abennett@mabelslabels.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>

* 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 <miner@example.com>
Co-authored-by: Rich Harris <rich.harris@vercel.com>

* Version Packages (#17585)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* Revert "drop useless tests"

This reverts commit 65f77ef840.

* 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 <rich.harris@vercel.com>
Co-authored-by: Antonio Bennett <31296212+Antonio-Bennett@users.noreply.github.com>
Co-authored-by: Antonio Bennett <abennett@mabelslabels.com>
Co-authored-by: FORMI <239411042+Richman018@users.noreply.github.com>
Co-authored-by: Miner <miner@example.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
pull/17515/merge
7nik 5 days ago committed by GitHub
parent 4f41e816ba
commit d7a8e3d130
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: emit `each_key_duplicate` error in production

@ -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(

@ -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<any>} 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);
}
}

@ -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';

@ -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

@ -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');
}
});

@ -0,0 +1,8 @@
<script>
let data = $state([1, 2, 3]);
</script>
<button onclick={() => data = [1, 1, 1]}>add</button>
{#each data as d (d)}
{d}
{/each}

@ -0,0 +1,5 @@
import { test } from '../../test';
export default test({
error: 'each_key_duplicate'
});

@ -0,0 +1,7 @@
<script>
let data = [1, 1, 1];
</script>
{#each data as d (d)}
{d}
{/each}

@ -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');
}
});

@ -0,0 +1,8 @@
<script>
let data = $state([1, 2, 3, 4, 5]);
</script>
<button onclick={() => data = [1, 1, 3, 1]}>add</button>
{#each data as d (d)}
{d}
{/each}
Loading…
Cancel
Save