Skip to content

feat(no-mutating-props): report nested mutation of defineModel() refs - #3131

Draft
faizanu94 wants to merge 1 commit into
vuejs:masterfrom
faizanu94:feat/no-mutating-props-definemodel
Draft

feat(no-mutating-props): report nested mutation of defineModel() refs#3131
faizanu94 wants to merge 1 commit into
vuejs:masterfrom
faizanu94:feat/no-mutating-props-definemodel

Conversation

@faizanu94

Copy link
Copy Markdown

Refs #3130

Summary

defineModel() returns a ref that bridges a prop and its update:modelValue emit. Reassigning model.value goes through the ref setter and emits the update, but mutating a nested property in place bypasses the setter, never emits, and silently desyncs the parent:

<script setup>
const model = defineModel()

// ✗ bypasses update:modelValue — silently desyncs the parent
model.value.name = 'x'
model.value.items[0] = 'y'
model.value.items.push('z')

// ✓ goes through the ref setter, emits update:modelValue
model.value = { ...model.value, name: 'x' }
</script>

vue/no-mutating-props already reports this exact class of bug for defineProps(), but ignored defineModel() because it only registered onDefinePropsEnter. This wires defineModel() bindings into the same utils.findMutating pipeline used for destructured props, via the existing onDefineModelEnter hook (added in #2360). This follows the same "teach an existing rule about defineModel()" pattern as #2360 and #3032.

Behavior

  • Reports nested mutation of a defineModel() ref: property assignment, index assignment, arbitrary depth, ++/delete, mutating array methods (push, splice, …), and Object.assign(model.value, …).
  • Allows reassigning the ref itself (model.value = next), which is the correct update pattern.
  • shallowOnly: true continues to permit nested mutation, consistent with its meaning for props.
  • No change to defineProps() / Options API behavior.

Known limitation (deliberate, out of scope for this PR)

This is a syntactic check on direct model.value.<path> mutation. It does not track mutation through an intermediate alias (const i = model.value.items[0]; i.x = 1), which would require data-flow analysis. Happy to note this in the docs or track it separately if preferred.

Test plan

  • Added valid + invalid cases to tests/lib/rules/no-mutating-props.test.ts (nested property/index/depth, mutating calls, Object.assign, named model, correct-reassign patterns, unrelated locals, shallowOnly, and a defineProps regression guard).
  • npx vitest run tests/lib → 288 files, 11628 tests pass.
  • npx eslint on changed files and npx markdownlint "**/*.md" clean.
  • npm run build succeeds.
  • Added a minor changeset.

@changeset-bot

changeset-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0f53763

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
eslint-plugin-vue Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

`defineModel()` returns a ref that bridges a prop and its `update:modelValue`
emit. Reassigning `model.value` triggers the emit, but mutating a nested
property in place (`model.value.foo = 1`, `model.value.items.push(1)`,
`Object.assign(model.value, {...})`) bypasses the setter, never emits, and
silently desyncs the parent.

`vue/no-mutating-props` already reports this class of bug for `defineProps()`
but ignored `defineModel()`, since it only registered `onDefinePropsEnter`.
This wires `defineModel()` bindings into the same `findMutating` pipeline via
`onDefineModelEnter`, reusing the existing detection logic. Reassigning the ref
itself remains allowed, and `shallowOnly` continues to permit nested mutation.

Refs vuejs#3130
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant