correctness/prop-mutation · Mutated non-bindable prop
Don't mutate a prop from $props() unless it is declared $bindable.
Severity: warning · Category: correctness
What it checks
Flags a mutation of a value destructured from $props() that is not declared $bindable: a member write (user.name = …, obj.count += 1), delete obj.x, or a mutating method call (items.push(…), arr.splice(…), map.set(…), …). Those methods are synchronous, so a call awaited or chained with .then/.catch/.finally (await db.items.clear()) is a client’s own API and is not flagged. Neither is a method called directly on a prop whose type in the $props() annotation is not a collection: an object type, or a named type other than Array, Set, Map and the other built-in collections (queryClient.clear() on a QueryClient), followed through a same-file alias or interface. A call whose result goes back into the prop (problem = problem.clear(name)) is still flagged, since the method may return the object it changed, and so is a named type with type arguments (ReturnType<…>). A name imported from another module is taken as not a collection, so a prop typed with an imported alias of an array type (type Items = Item[]) is missed. A ...rest binding is tracked too, since rest props can never be individually declared $bindable.
Plain reassignment of the prop itself (count = 5) is not flagged: Svelte’s docs explicitly sanction temporary reassignment for unsaved ephemeral state. Only mutation is prohibited.
A local reusing the prop’s name shadows it and is not the prop at all, so mutating it is not flagged: a function or arrow parameter, a block-scoped let/const redeclaration, a for/for-of/for-in loop variable, a catch parameter, or a {#each ... as x} variable.
{#snippet}/{:then}/{:catch} bindings are not tracked and could still produce a false positive. This is a deliberately partial mitigation, not full scope resolution.
A prop holding a class instance with $state fields is still flagged when you mutate it through its fields (todo.done = true), or through its methods when the prop has no type annotation (cart.add(item)), even though that mutation is reactive: static analysis cannot tell a class instance from a plain object. If that is the intended pattern, suppress the occurrence inline.
Why it matters
Svelte’s docs say plainly: “don’t mutate props” unless they are $bindable. Three failure modes, none caught by the compiler:
- A plain-object prop mutation is a silent no-op: the object isn’t a state proxy, so not even the dev-time warning fires.
- A reactive-state-proxy prop mutation works, but triggers the
ownership_invalid_mutationdev warning, and only if that code path is actually exercised at runtime. - A fallback value in use behaves like a plain object, so mutation has no effect.
Static analysis catches all three at review/CI time, before the code path has to run.
How to fix
<script>
let { user } = $props();
// Instead of mutating the prop directly:
function rename(name) {
user.name = name; // no-op or ownership_invalid_mutation warning
}
// Clone before mutating:
function rename(name) {
const next = { ...user, name };
// ...use `next`, or lift the change to the parent
}
// Or make it bindable, if the parent and child should share it:
let { user = $bindable() } = $props();
</script>
Legacy mode (export let)
The same class of bug exists in legacy-mode components, for a different reason. Svelte’s legacy reactivity is assignment-based, so a mutating method call never triggers an update on its own, even when the prop is passed with bind::
<script>
export let items;
// flagged — the mutation itself doesn't trigger an update
function addItem(item) {
items.push(item);
}
</script>
The same goes for delete items.x. A member write or update (user.name = …, user.count++) is different: it is itself an assignment, which the compiler turns into an update of the prop, so in legacy mode it is not flagged.
Reassign the prop after mutating it to re-trigger reactivity. This is Svelte’s own documented pattern, not a workaround:
<script>
export let items;
function addItem(item) {
items.push(item);
items = items; // tells the compiler `items` changed
}
</script>
A mutating call is not flagged when the same function, or the same $: statement ($: data = data.sort(…)), also reassigns the prop. A reassignment in a different function does not count.
Mode differences
None. This rule reads source, the same .svelte and .ts files, everywhere it runs. The CLI, the Vite plugin’s build pass, and the live dashboard’s static baseline all report it identically, and the rendered-HTML pass never re-evaluates it. Scoping a run with --route skips it: component-scoped rules have no route to attribute a finding to.
Disabling
Silence a single occurrence with <!-- svelte-vitals-disable-next-line correctness/prop-mutation --> on the line above it, or turn the rule off:
export default {
rules: {
'correctness/prop-mutation': 'off'
}
};