correctness/prop-mutation · 非 bindable prop の変異
$bindable を宣言していない $props() の値は書き換えないでください。
重大度: warning · カテゴリ: correctness
チェック内容
$props() から分割代入された値のうち $bindable を宣言していないものへの変異を検出します。メンバー書き込み(user.name = …、obj.count += 1)、delete obj.x、変異メソッド呼び出し(items.push(…)、arr.splice(…)、map.set(…) など)です。これらのメソッドは同期的なので、await したり .then/.catch/.finally でつないだりした呼び出し(await db.items.clear())はクライアント自身の API とみなし、検出しません。$props() の型注釈でコレクションでない型を付けた prop に対して直接呼んだメソッドも検出しません。コレクションでない型とは、オブジェクト型か、Array・Set・Map などの組み込みコレクション以外の名前付きの型です(QueryClient 型の queryClient.clear())。同じファイルの型エイリアスやインターフェースはたどって判断します。ただし、呼び出しの結果を同じ prop に戻す場合(problem = problem.clear(name))は、メソッドが変更したオブジェクト自身を返すことがあるため検出します。型引数を持つ名前付きの型(ReturnType<…>)も検出の対象のままです。別のモジュールからインポートした型名はコレクションでないものとして扱うので、配列型のエイリアス(type Items = Item[])をインポートして型を付けた prop は見逃します。...rest で受けたバインディングも対象です。rest props は個別に $bindable を宣言できないためです。
prop 自体への単純な再代入(count = 5)は対象外です。Svelte の公式ドキュメントは一時的な状態保持のための再代入を明示的に許容しており、禁止されているのは変異のみです。
prop と同名のローカルは prop をシャドーイングしており prop 自体ではないため、変異させても検出しません。対象は関数やアロー関数のパラメータ、ブロックスコープの let/const 再宣言、for/for-of/for-in のループ変数、catch 節のパラメータ、{#each ... as x} のループ変数です。
{#snippet}/{:then}/{:catch} のバインディングは追跡しておらず、理論上は誤検出につながり得ます。意図して部分的な緩和策にとどめており、完全なスコープ解決ではありません。
$state フィールドを持つクラスのインスタンスを受け取った prop も、フィールド経由で変更したとき(todo.done = true)や、型注釈のない prop をメソッド経由で変更したとき(cart.add(item))は、その変更がリアクティブであっても検出されます。静的解析ではクラスのインスタンスとプレーンなオブジェクトを区別できないためです。意図したパターンであれば、その箇所をインラインで抑制してください。
なぜ重要か
Svelte の公式ドキュメントは明確に「$bindable でない限り prop を変異させてはいけない」と述べています。コンパイラが捕まえない失敗モードが3つあります。
- プレーンオブジェクトの prop を変異させても、オブジェクトが state proxy でないため黙って無視されます(開発時の警告すら出ません)。
- リアクティブな state proxy の prop を変異させると動作はしますが、
ownership_invalid_mutationという開発時警告が出ます。ただしそれはそのコードパスが実際に実行された場合のみです。 - フォールバック値が使われている場合もプレーンオブジェクトと同様に振る舞い、変異は反映されません。
静的解析であれば、コードパスが実行される前のレビューや CI の時点でこの3つすべてを捕まえられます。
修正方法
<script>
let { user } = $props();
// prop を直接変異させる代わりに:
function rename(name) {
user.name = name; // 何も起きないか、ownership_invalid_mutation 警告が出る
}
// 変異前にクローンする:
function rename(name) {
const next = { ...user, name };
// next を使うか、変更を親に持ち上げる
}
// 親子で共有すべきなら bindable にする:
let { user = $bindable() } = $props();
</script>
legacy mode(export let)
同じ種類のバグは legacy mode のコンポーネントにも、別の理由で存在します。Svelte の legacy なリアクティビティは代入ベースなので、bind: で渡された prop であっても、変異メソッド呼び出しだけでは更新がトリガーされません。
<script>
export let items;
// 検出対象 — 変異自体は更新をトリガーしない
function addItem(item) {
items.push(item);
}
</script>
delete items.x も同じです。メンバーへの代入や更新(user.name = …、user.count++)は事情が異なります。それ自体が代入なので、コンパイラが prop の更新に変換します。そのため legacy mode では検出しません。
リアクティビティを再トリガーするには、変異後に prop を再代入してください。これは回避策ではなく、Svelte 自身が公式に示しているパターンです。
<script>
export let items;
function addItem(item) {
items.push(item);
items = items; // items が変わったことをコンパイラに伝える
}
</script>
同じ関数の中、または同じ $: 文の中($: data = data.sort(…))で prop を再代入していれば、変異メソッド呼び出しは検出しません。別の関数での再代入は対象外です。
モードによる違い
ありません。このルールが読むのは同じ .svelte / .ts のソースファイルなので、CLI、Vite プラグインのビルド、ライブダッシュボードの静的ベースラインのいずれでも結果は同一で、レンダリング済み HTML の解析で再評価されることもありません。--route で実行範囲を絞ると、このルールは動きません。コンポーネントスコープのルールには、検出を紐づけるルートが無いためです。
無効化
個別に抑制するには、対象行の直前に <!-- svelte-vitals-disable-next-line correctness/prop-mutation --> を置きます。ルールごと無効化するには、次のように設定します。
export default {
rules: {
'correctness/prop-mutation': 'off'
}
};