Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-snakes-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

fix: emit `each_key_duplicate` error in production
Original file line number Diff line number Diff line change
Expand Up @@ -337,10 +337,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 (has_await) {
context.state.init.push(
b.stmt(
Expand Down
26 changes: 26 additions & 0 deletions packages/svelte/src/internal/client/dom/blocks/each.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import { active_effect, 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 { each_key_duplicate } from '../../errors.js';
import { validate_each_keys } from '../../validate.js';

/**
* The row of a keyed each block that is currently updating. We track this
Expand Down Expand Up @@ -201,6 +203,11 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
}
was_empty = length === 0;

// skip if #each block isn't keyed
if (DEV && get_key !== index) {
validate_each_keys(array, get_key);
}

/** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */
let mismatch = false;

Expand Down Expand Up @@ -266,6 +273,8 @@ export function each(node, flags, get_collection, get_key, render_fn, fallback_f
if (hydrating) {
if (length === 0 && fallback_fn) {
fallback = branch(() => fallback_fn(anchor));
} else if (length > state.items.size) {
each_key_duplicate('', '', '');
}
} else {
if (should_defer_append()) {
Expand Down Expand Up @@ -363,6 +372,7 @@ function reconcile(
var is_animated = (flags & EACH_IS_ANIMATED) !== 0;
var should_update = (flags & (EACH_ITEM_REACTIVE | EACH_INDEX_REACTIVE)) !== 0;

var count = 0;
var length = array.length;
var items = state.items;
var first = state.first;
Expand Down Expand Up @@ -451,6 +461,7 @@ function reconcile(
stashed = [];

current = prev.next;
count += 1;
continue;
}

Expand All @@ -473,6 +484,12 @@ function reconcile(
var start = stashed[0];
var j;

// full key uniqueness check is dev-only,
// key duplicates cause crash only due to `matched` being empty
if (matched.length === 0) {
each_key_duplicate('', '', '');
}

prev = start.prev;

var a = matched[0];
Expand Down Expand Up @@ -506,6 +523,7 @@ function reconcile(
link(state, prev, item);

prev = item;
count += 1;
}

continue;
Expand Down Expand Up @@ -534,6 +552,14 @@ function reconcile(
matched.push(item);
prev = item;
current = item.next;
count += 1;
}

if (count !== length) {
// full key uniqueness check is dev-only,
// if keys duplication didn't cause a crash,
// the rendered list will be shorter then the array
each_key_duplicate('', '', '');
}

if (current !== null || seen !== undefined) {
Expand Down
38 changes: 15 additions & 23 deletions packages/svelte/src/internal/client/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,27 @@ import * as w from './warnings.js';
import { capture_store_binding } from './reactivity/store.js';

/**
* @param {() => any} collection
* @param {Array<any>} array
* @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);
export 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;
/** @type {string | null} */
let k = String(key);
if (k.startsWith('[object ')) k = null;

e.each_key_duplicate(a, b, k);
}
keys.set(key, i);
e.each_key_duplicate(a, b, k);
}
});
keys.set(key, i);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { flushSync } from 'svelte';
import { test } from '../../test';

export default test({
compileOptions: {
dev: false
},
test({ assert, target }) {
let button = target.querySelector('button');

button?.click();

assert.throws(flushSync, /each_key_duplicate/);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
let data = [1, 2, 3];
</script>

<button onclick={() => data = [1, 1, 1]}>add</button>
{#each data as d (d)}
{d}
{/each}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { test } from '../../test';

export default test({
compileOptions: {
dev: false
},
error: 'each_key_duplicate\nKeyed each block has duplicate key at indexes and '
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
let data = [1, 1, 1];
</script>

{#each data as d (d)}
{d}
{/each}
Original file line number Diff line number Diff line change
@@ -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, /each_key_duplicate/);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<script>
let data = [1, 2, 3];
</script>

<button onclick={() => data = [1, 1, 3, 1]}>add</button>
{#each data as d (d)}
{d}
{/each}
Loading