Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/each-index-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@svelte-vitals/core': minor
'svelte-vitals': minor
'@svelte-vitals/vite': minor
'@svelte-vitals/mcp': minor
---

Add `correctness/each-index-key`: flags `{#each}` blocks keyed by their own index (`{#each items as item, i (i)}`) — position-based identity, the same failure mode as an unkeyed block, masked by the visible key.

Also widens the no-identity exemption shared with correctness/each-key: length-only placeholder lists (Array(n), [...Array(n)], Array.from({length: n})) are no longer flagged by either rule.
41 changes: 41 additions & 0 deletions docs/src/content/docs/ja/rules/correctness/each-index-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: correctness/each-index-key · Index used as each key
description: '{#each} ブロックのキーに index を使うと、アイテムの同一性が位置ベースになります。キーなしと同じバグが、隠れた形で起こります。'
---

**重大度:** warning · **カテゴリ:** correctness

## チェック内容

キーが index 束縛そのものになっている `{#each}` ブロック(例: `{#each items as item, i (i)}`)を検出します。CLI の静的解析が `src/` 配下のすべての `.svelte` コンポーネントを対象にチェックします。

index の単純な文字列化(`(String(i))`、`(Number(i))`、``(`${i}`)``、`(i.toString())`、`(i + '')`)や、`(i!)` を含む TS ラップ形式も検出します。これらも位置ベースの同一性であることに変わりはありません。

検出しないもの: アイテムのデータと index を組み合わせた複合キー(`(item.id + '-' + i)`、``(`${item.id}-${i}`)``)。重複アイテムを含むリストでは素のアイテムキーが Svelte の duplicate-key エラーを投げるため、index の付加が意図的な回避策になっている場合があります。ただしトレードオフに注意してください。このキーもアイテムが移動すると変わるため、移動したアイテムは追跡されずに破棄・再生成されます。可能なら真に一意な id を使うのが望ましいです。長さのみのプレースホルダーリスト(`{#each [...Array(n)] as _, i (i)}` など)もアイテムの同一性を持たないため、検出対象外です。

## 重要な理由

Svelte 公式ガイダンスは明確です。キーはオブジェクトを一意に識別しなければならず、index をキーに使ってはいけません。index キーではアイテムの同一性がリスト内の位置に従うため、並べ替えや途中への挿入・削除が起きると、要素の状態(フォーカス、入力値、トランジション)がアイテムではなく位置に張り付きます。これはキーなしブロックとまったく同じ故障モードです。しかもキーが見えている分だけ安全そうに見え、バグはレビューではなく本番で発覚しがちです。

## 修正方法

アイテムを一意に識別する値でキーを付けます:

```svelte
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
```

## 無効化

リストが並べ替えも途中挿入・削除も起こさないと確実に言える場合は、`<!-- svelte-vitals-disable-next-line correctness/each-index-key -->` で個別に抑制するか、ルールを無効化してください:

```js
// svelte-vitals.config.mjs
export default {
rules: {
'correctness/each-index-key': 'off'
}
};
```
3 changes: 2 additions & 1 deletion docs/src/content/docs/ja/rules/correctness/each-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ description: 動的データの {#each} にはキーを付けるべきです。

## チェック内容

キーのない `{#each}` ブロックを検出します。`src/` 配下のすべての `.svelte` コンポーネントを静的(CLI)解析します。次の2つの形は対象外とします:
キーのない `{#each}` ブロックを検出します。`src/` 配下のすべての `.svelte` コンポーネントを静的(CLI)解析します。次の形は対象外とします:

- 定数のインライン配列リテラル(`{#each [1, 2, 3] as n}`)。長さが固定で並べ替えが起きないため、キーを付けても意味がありません。
- アイテムを取らない each(`{#each { length: 8 }, i}` という「N 回描画する」パターン)。キーを付けられるようなアイテムの同一性が存在せず、唯一付けられる index 自体をキーにしても無意味です。
- 長さのみのリスト(`Array(n)`、`[...Array(n)]`、`Array.from({ length: n })`)。順序に意味のない固定長のプレースホルダー/スケルトンリストであり、キーを付けても意味がありません。

## なぜ重要か

Expand Down
41 changes: 41 additions & 0 deletions docs/src/content/docs/rules/correctness/each-index-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
title: correctness/each-index-key · Index used as each key
description: Keying an {#each} block by its index gives items position-based identity — the same bug as no key, masked.
---

**Severity:** warning · **Category:** correctness

## What it checks

Flags an `{#each}` block whose key is exactly its index binding, e.g. `{#each items as item, i (i)}`. Checked by static (CLI) analysis of every `.svelte` component under `src/`.

Trivial stringifications of the index — `(String(i))`, `(Number(i))`, ``(`${i}`)``, `(i.toString())`, `(i + '')`, and TS-wrapped forms including `(i!)` — are flagged too: they are still position-based identity.

Not flagged: composite keys that contain the index alongside item data (`(item.id + '-' + i)`, ``(`${item.id}-${i}`)``) — appending an index is sometimes a deliberate workaround for lists with duplicate items, where a bare item key would throw Svelte's duplicate-key error. Note the trade-off: such a key still changes when an item moves position, so moved items are destroyed and recreated instead of tracked — prefer a truly unique id when you can. Also not flagged: length-only placeholder lists — `{#each [...Array(n)] as _, i (i)}` and friends — have no item identity and are skipped entirely.

## Why it matters

Svelte's own guidance is explicit: the key must uniquely identify the object — do not use the index as a key. An index key makes item identity follow list position, so when the list reorders or items are inserted or removed, element state (focus, input values, transitions) sticks to positions instead of items — exactly the failure mode of an unkeyed block. Worse, the visible key makes the block look safe, so the bug tends to surface in production instead of review.

## How to fix

Key by a value that uniquely identifies the item:

```svelte
{#each items as item (item.id)}
<li>{item.name}</li>
{/each}
```

## Disabling

If a list provably never reorders and never has mid-list insertions or removals, you can silence a single block with `<!-- svelte-vitals-disable-next-line correctness/each-index-key -->`, or turn the rule off:

```js
// svelte-vitals.config.mjs
export default {
rules: {
'correctness/each-index-key': 'off'
}
};
```
3 changes: 2 additions & 1 deletion docs/src/content/docs/rules/correctness/each-key.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ description: An {#each} block over dynamic data should have a key.

## What it checks

Flags an `{#each}` block with no key. Checked by static (CLI) analysis of every `.svelte` component under `src/`. Two shapes are ignored:
Flags an `{#each}` block with no key. Checked by static (CLI) analysis of every `.svelte` component under `src/`. A few shapes are ignored:

- A constant inline array literal (`{#each [1, 2, 3] as n}`) — it has a fixed length and never reorders, so a key cannot help.
- An itemless each (`{#each { length: 8 }, i}`, the "render N times" pattern) — there is no item identity to key on; the only possible key is the index itself, which is a no-op.
- Length-only lists (`Array(n)`, `[...Array(n)]`, `Array.from({ length: n })`) — placeholder/skeleton lists with a fixed, order-free shape a key cannot help.

## Why it matters

Expand Down
Loading