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
40 changes: 40 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,9 @@ 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';
import { invoke_error_boundary } from '../../error-handling.js';

/**
* The row of a keyed each block that is currently updating. We track this
Expand Down Expand Up @@ -201,6 +204,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 +274,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 +373,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 +462,7 @@ function reconcile(
stashed = [];

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

Expand All @@ -473,6 +485,19 @@ 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) {
// reconcile can be called in the batch's callbacks which are
// executed outside of the effect tree, so error are not caught
try {
each_key_duplicate('', '', '');
} catch (error) {
invoke_error_boundary(error, each_effect);
return;
}
}

prev = start.prev;

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

prev = item;
count += 1;
}

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

// Full key uniqueness check is dev-only. If keys duplication didn't cause a crash,
// the rendered list will be shorter then the source array
if (count !== length) {
// reconcile can be called in the batch's callbacks which are
// executed outside of the effect tree, so error are not caught
try {
each_key_duplicate('', '', '');
} catch (error) {
invoke_error_boundary(error, each_effect);
return;
}
}

if (current !== null || seen !== undefined) {
Expand Down
39 changes: 15 additions & 24 deletions packages/svelte/src/internal/client/validate.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,32 @@
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';

/**
* @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
Loading