a11y/id-duplication · id の重複
ルート内の id はすべて一意にしましょう。
重大度: warning · カテゴリ: a11y
チェック内容
ルートを構成するレイアウトチェーン(+layout.svelte から +page.svelte まで)と、そこから解決したローカルコンポーネントを合わせて、同じリテラルな id の値が2回以上現れる場合を検出します。分岐を考慮して判定し、{#if}/{#await} ブロックでは出現数が最も多いアーム(同数ならドキュメント順で先のアーム)だけを数えるため、1 つの {#if} のアーム同士が合算されることはありません。ただし別々の {#if} ブロック同士は独立です。{#if a}…{/if}{#if !a}…{/if} は両方描画されるものとして数えます。条件式を評価するわけではないためです(a が後述の false のフラグである場合を除きます)。ページ(と内側のレイアウト)は親レイアウトが {@render children()} または <slot /> を描画する位置に置かれるので、レイアウトの別のアームもページと排他的に扱います。1 つの {#if} の複数のアームで描画するレイアウトでは、ページをそれぞれのアームに置きます。別々のブロックに分かれている場合は、共通する位置に置きます。page.url.pathname($page.url.pathname も。直接でも $derived 経由でも)を文字列リテラルと startsWith・endsWith・includes・=== で比べる分岐は、そのルートのパスでは成り立たない場合に除外します。同じ読み取りで、レイアウトやページのどの {#if} もルートごとに判定します。条件には、page.route.id($page.route.id)と文字列リテラルの比較や配列リテラルか、ファイル内で書き換えない文字列だけの const 配列の includes(['/a', '/b'].includes(page.route.id))、正規表現リテラルによるパスの .test()、同じ配列の要素ごとにパスを startsWith・endsWith・includes で調べる .some(PUBLIC.some((r) => page.url.pathname.startsWith(r)))、パスとテンプレートリテラルの ===(`/c/${slug}`。各 ${…} はその位置のルートパラメータとみなします)も使えます。アプリ自身のモジュールがリテラル false の const として名前付きで export する値(export const FLAG = false)を import し、その値だけで結果が決まる {#if} では、描画されない側の分岐も除外します。除外するのは {#if FLAG} と {#if FLAG && …} の最初の分岐、{#if !FLAG} と {#if !FLAG || …} の {:else} です。{#if !FLAG && …} と {#if FLAG || …} は、もう一方の条件しだいなので判定しません。その結果レイアウトが children を描画する位置が残らない場合、そのルートではページとその下のレイアウトを数えません。コンポーネント内で 1 つの prop だけで決まる {#if} は、呼び出し側がその prop をリテラルで渡すか渡さない場合、その prop が選ぶ分岐だけを数えます(seo/single-h1 と同じ)。そうした判定を && や || でつないだ条件({#if showcase && layout === 'cover'})も、呼び出し側がそれぞれを決めるなら判定します。分割代入しない $props() の束縛のメンバーとして prop を読む場合({#if props.title})も同じです。そうした判定を保持する $derived に対する {#if}($derived(variant === 'embedded') の {#if embedded})も同じです。
id はファイルをまたいで検出します。+layout.svelte の id="search" と +page.svelte(やインポートした $lib コンポーネント)の別の id="search" は、どちらのファイル単体を見ても問題なく見えますが、1つの重複です。ファイル単位のマークアップ linter は、レイアウトとページを合成して初めて存在するこの重複を検出できません。
検出しないもの:
- すべての id が一意なルート。
{#each}の中にある id。ループは0〜N回描画されうるため、重複するかどうかは静的には判定できません。実際に重複が発生するケース(N≥2回の描画)は、レンダリングモードが実行時に検出します。これは仕様上の意図的な違いです。- 動的な id(
id={expr})。実行時の値が静的にはわからないため対象外です。
なぜ重要か
id が重複すると、id の存在意義そのものが壊れます。<label for>・aria-labelledby・aria-describedby はその id を持つ最初の要素に解決され、ページ内フラグメントナビゲーション(#anchor)も最初に一致した要素へジャンプします。レイアウトとページが同じ id を導入してしまった場合によくあるように、作者の意図が2つ目の要素にあったとしても、支援技術やフラグメントリンクは見た目には何の異常もないまま、意図しない要素を指してしまいます。
修正方法
重複している id のどちらか一方を、ルート内で一意になるように名前を変えます。
<nav id="site-search">...</nav>
<section id="page-search">...</section>
<!-- id="site-search" だったものをレイアウトとの重複を避けて変更 -->
どの出現も同じ行を指すなら、その id はルート上で複数回描画されるコンポーネントに直書きされています。名前を変えても解消しないため、検出結果もそう案内します。インスタンスごとに $props.id()(Svelte 5.20 以降)で id を生成してください。
<script>
const uid = $props.id();
</script>
<label for="{uid}-name">Name</label>
<input id="{uid}-name" />
同時には描画されない別々の {#if} ブロックに出現しているなら、1 つの {#if}… の分岐にまとめると、排他的な分岐として扱われます。
モードによる違い
両モードとも id を収集しますが、収集元が異なるため結果が食い違うことがあります。
- ソース解析(CLI、ダッシュボードの静的ベースライン)は、ルートのレイアウトチェーンと、そこから解決したローカルコンポーネントを、分岐を考慮した判定で合成します。
{#if}/{#await}ブロックでは出現数が最も多いアームだけを数えるため、実際にはレンダリングされないアームを選んでしまうことがあります。解決できないコンポーネント(node_modulesや、実行時にデータや対応表から選ばれるコンポーネント)が持つ id は見えず、{#each}の中身は重複するかどうかが静的には判定できないため対象外です。 - 両モードとも、
src/app.htmlの id と衝突するページ側の id を報告します。ソース解析はシェル側の出現を「最初の、検出対象外の代表」として先頭に挿入するため、検出はルート側の出現に付き、メッセージにはシェルの名前が入ります(— also defined by the src/app.html shell (line N))。 - レンダリング解析(Vite プラグインのビルド、ダッシュボードで訪問したルート)はレンダリング済みの HTML を読みます。そのため、実際にレンダリングされた id だけを認識します。
{#each}が生成した id もすべてここに含まれます。ループ由来の実際の重複は、このモードでのみ表面化します。ソースファイルを持たないため、検出結果の位置は特定のファイル・行ではなくルート自体に紐づきます。同じ不具合でも、永続化される検出キーはモードによって異なります。
両者が食い違う場合は、レンダリング結果を信頼してください。ブラウザに配信される内容を反映しているのはそちらです。
無効化
検出が指す行の直上にインラインの svelte-vitals-disable-next-line コメントを置けば抑制できます(ソース解析のみ。ビルドの検出はプリレンダリングされた HTML を指すため、直上に置くべきソース行がありません)。その行が合成されたコンポーネント内にある場合、1 つのディレクティブがそれを合成するすべてのルートで効きます。ルート単位で抑制したい場合は suppressions ファイル(npx svelte-vitals --update-suppressions)を使ってください。overrides でルートやパス単位に絞るか、ルールごと無効化するには、次のように設定します。
export default {
rules: {
'a11y/id-duplication': 'off'
}
};