]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(compiler-sfc): demote const reactive bindings used in v-model (#14214)
authoredison <daiwei521@126.com>
Thu, 18 Dec 2025 08:24:07 +0000 (16:24 +0800)
committerGitHub <noreply@github.com>
Thu, 18 Dec 2025 08:24:07 +0000 (16:24 +0800)
close #11265
close #11275

packages/compiler-core/__tests__/transforms/vModel.spec.ts
packages/compiler-core/src/errors.ts
packages/compiler-core/src/transforms/vModel.ts
packages/compiler-dom/src/errors.ts
packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap
packages/compiler-sfc/__tests__/compileScript.spec.ts
packages/compiler-sfc/src/compileScript.ts
packages/compiler-sfc/src/script/importUsageCheck.ts

index 82dd4909fd653e0faecff7db07582cb19841fdf5..1f27bad5c222b20f26afb8adc93493c51350c75e 100644 (file)
@@ -582,5 +582,22 @@ describe('compiler: transform v-model', () => {
         }),
       )
     })
+
+    test('used on const binding', () => {
+      const onError = vi.fn()
+      parseWithVModel('<div v-model="c" />', {
+        onError,
+        bindingMetadata: {
+          c: BindingTypes.LITERAL_CONST,
+        },
+      })
+
+      expect(onError).toHaveBeenCalledTimes(1)
+      expect(onError).toHaveBeenCalledWith(
+        expect.objectContaining({
+          code: ErrorCodes.X_V_MODEL_ON_CONST,
+        }),
+      )
+    })
   })
 })
index 58e113ab19eef2d27e3e3e950d5651592748c443..ea8e8f27048c0e67749689873ee9bd21b429d970 100644 (file)
@@ -88,6 +88,7 @@ export enum ErrorCodes {
   X_V_MODEL_MALFORMED_EXPRESSION,
   X_V_MODEL_ON_SCOPE_VARIABLE,
   X_V_MODEL_ON_PROPS,
+  X_V_MODEL_ON_CONST,
   X_INVALID_EXPRESSION,
   X_KEEP_ALIVE_INVALID_CHILDREN,
 
@@ -176,6 +177,7 @@ export const errorMessages: Record<ErrorCodes, string> = {
   [ErrorCodes.X_V_MODEL_MALFORMED_EXPRESSION]: `v-model value must be a valid JavaScript member expression.`,
   [ErrorCodes.X_V_MODEL_ON_SCOPE_VARIABLE]: `v-model cannot be used on v-for or v-slot scope variables because they are not writable.`,
   [ErrorCodes.X_V_MODEL_ON_PROPS]: `v-model cannot be used on a prop, because local prop bindings are not writable.\nUse a v-bind binding combined with a v-on listener that emits update:x event instead.`,
+  [ErrorCodes.X_V_MODEL_ON_CONST]: `v-model cannot be used on a const binding because it is not writable.`,
   [ErrorCodes.X_INVALID_EXPRESSION]: `Error parsing JavaScript expression: `,
   [ErrorCodes.X_KEEP_ALIVE_INVALID_CHILDREN]: `<KeepAlive> expects exactly one child component.`,
   [ErrorCodes.X_VNODE_HOOKS]: `@vnode-* hooks in templates are no longer supported. Use the vue: prefix instead. For example, @vnode-mounted should be changed to @vue:mounted. @vnode-* hooks support has been removed in 3.4.`,
index 598c1ea4387205012a38dc0606d1926e5baa4820..ff722db9b5437b824b7c6192274a49f95e398798 100644 (file)
@@ -48,6 +48,15 @@ export const transformModel: DirectiveTransform = (dir, node, context) => {
     return createTransformProps()
   }
 
+  // const bindings are not writable.
+  if (
+    bindingType === BindingTypes.LITERAL_CONST ||
+    bindingType === BindingTypes.SETUP_CONST
+  ) {
+    context.onError(createCompilerError(ErrorCodes.X_V_MODEL_ON_CONST, exp.loc))
+    return createTransformProps()
+  }
+
   const maybeRef =
     !__BROWSER__ &&
     context.inline &&
index faf6fb564413b5b58f9769290d41f638a74cf2a8..f7a5a44f913af76a4e339259e3c2c4c4c358108a 100644 (file)
@@ -21,7 +21,7 @@ export function createDOMCompilerError(
 }
 
 export enum DOMErrorCodes {
-  X_V_HTML_NO_EXPRESSION = 53 /* ErrorCodes.__EXTEND_POINT__ */,
+  X_V_HTML_NO_EXPRESSION = 54 /* ErrorCodes.__EXTEND_POINT__ */,
   X_V_HTML_WITH_CHILDREN,
   X_V_TEXT_NO_EXPRESSION,
   X_V_TEXT_WITH_CHILDREN,
index 2acac64b0fbce9c2e195936e31285e0a90dcd864..795f0ae8840a5da27a7cb76742fce86be8e28011 100644 (file)
@@ -639,6 +639,44 @@ return { foo, bar, baz, y, z }
 }"
 `;
 
+exports[`SFC compile <script setup> > demote const reactive binding to let when used in v-model (inlineTemplate) 1`] = `
+"import { unref as _unref, resolveComponent as _resolveComponent, isRef as _isRef, openBlock as _openBlock, createBlock as _createBlock } from "vue"
+
+import { reactive } from 'vue'
+      
+export default {
+  setup(__props) {
+
+      let name = reactive({ first: 'john', last: 'doe' })
+      
+return (_ctx, _cache) => {
+  const _component_MyComponent = _resolveComponent("MyComponent")
+
+  return (_openBlock(), _createBlock(_component_MyComponent, {
+    modelValue: _unref(name),
+    "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (_isRef(name) ? (name).value = $event : name = $event))
+  }, null, 8 /* PROPS */, ["modelValue"]))
+}
+}
+
+}"
+`;
+
+exports[`SFC compile <script setup> > demote const reactive binding to let when used in v-model 1`] = `
+"import { reactive } from 'vue'
+      
+export default {
+  setup(__props, { expose: __expose }) {
+  __expose();
+
+      let name = reactive({ first: 'john', last: 'doe' })
+      
+return { get name() { return name }, set name(v) { name = v }, reactive }
+}
+
+}"
+`;
+
 exports[`SFC compile <script setup> > errors > should allow defineProps/Emit() referencing imported binding 1`] = `
 "import { bar } from './bar'
         
index b69bbca2e16711de636740d9b129e58f59c74b7e..5e547f6b287f3ef1768626752cbcbf1599b10c5a 100644 (file)
@@ -1,3 +1,4 @@
+import { vi } from 'vitest'
 import { BindingTypes } from '@vue/compiler-core'
 import {
   assertCode,
@@ -7,6 +8,15 @@ import {
 } from './utils'
 import { type RawSourceMap, SourceMapConsumer } from 'source-map-js'
 
+vi.mock('../src/warn', () => ({
+  warn: vi.fn(),
+  warnOnce: vi.fn(),
+}))
+
+import { warnOnce } from '../src/warn'
+
+const warnOnceMock = vi.mocked(warnOnce)
+
 describe('SFC compile <script setup>', () => {
   test('should compile JS syntax', () => {
     const { content } = compile(`
@@ -74,6 +84,77 @@ describe('SFC compile <script setup>', () => {
     assertCode(content)
   })
 
+  test('demote const reactive binding to let when used in v-model', () => {
+    warnOnceMock.mockClear()
+    const { content, bindings } = compile(`
+      <script setup>
+      import { reactive } from 'vue'
+      const name = reactive({ first: 'john', last: 'doe' })
+      </script>
+
+      <template>
+        <MyComponent v-model="name" />
+      </template>
+    `)
+
+    expect(content).toMatch(
+      `let name = reactive({ first: 'john', last: 'doe' })`,
+    )
+    expect(bindings!.name).toBe(BindingTypes.SETUP_LET)
+    expect(warnOnceMock).toHaveBeenCalledTimes(1)
+    expect(warnOnceMock).toHaveBeenCalledWith(
+      expect.stringContaining(
+        '`v-model` cannot update a `const` reactive binding',
+      ),
+    )
+    assertCode(content)
+  })
+
+  test('demote const reactive binding to let when used in v-model (inlineTemplate)', () => {
+    warnOnceMock.mockClear()
+    const { content, bindings } = compile(
+      `
+      <script setup>
+      import { reactive } from 'vue'
+      const name = reactive({ first: 'john', last: 'doe' })
+      </script>
+
+      <template>
+        <MyComponent v-model="name" />
+      </template>
+      `,
+      { inlineTemplate: true },
+    )
+
+    expect(content).toMatch(
+      `let name = reactive({ first: 'john', last: 'doe' })`,
+    )
+    expect(bindings!.name).toBe(BindingTypes.SETUP_LET)
+    expect(warnOnceMock).toHaveBeenCalledTimes(1)
+    expect(warnOnceMock).toHaveBeenCalledWith(
+      expect.stringContaining(
+        '`v-model` cannot update a `const` reactive binding',
+      ),
+    )
+    assertCode(content)
+  })
+
+  test('v-model should error on literal const bindings', () => {
+    expect(() =>
+      compile(
+        `
+        <script setup>
+        const foo = 1
+        </script>
+        <template>
+          <input v-model="foo" />
+        </template>
+        `,
+        { inlineTemplate: true },
+      ),
+    ).toThrow('v-model cannot be used on a const binding')
+  })
+
   describe('<script> and <script setup> co-usage', () => {
     test('script first', () => {
       const { content } = compile(`
index de7deded7e567f073f3d1c8fb45d512893711f71..a60aba17aec91eb7f52c21ff4664912a95ab6da3 100644 (file)
@@ -64,7 +64,10 @@ import {
   isTS,
 } from './script/utils'
 import { analyzeScriptBindings } from './script/analyzeScriptBindings'
-import { isImportUsed } from './script/importUsageCheck'
+import {
+  isImportUsed,
+  resolveTemplateVModelIdentifiers,
+} from './script/importUsageCheck'
 import { processAwait } from './script/topLevelAwait'
 
 export interface SFCScriptCompileOptions {
@@ -760,6 +763,55 @@ export function compileScript(
     ctx.bindingMetadata[key] = setupBindings[key]
   }
 
+  // #11265, https://github.com/vitejs/rolldown-vite/issues/432
+  // 6.1 demote `const foo = reactive()` to `let` when used as v-model target.
+  // In non-inline template compilation, v-model assigns via `$setup.foo = $event`,
+  // which requires a SETUP_LET binding (getter + setter) to keep script state in sync.
+  // In inline mode, it generates `foo = $event`, which also requires `let`.
+  if (sfc.template && !sfc.template.src && sfc.template.ast) {
+    const vModelIds = resolveTemplateVModelIdentifiers(sfc)
+    if (vModelIds.size) {
+      const toDemote = new Set<string>()
+      for (const id of vModelIds) {
+        if (setupBindings[id] === BindingTypes.SETUP_REACTIVE_CONST) {
+          toDemote.add(id)
+        }
+      }
+
+      if (toDemote.size) {
+        for (const node of scriptSetupAst.body) {
+          if (
+            node.type === 'VariableDeclaration' &&
+            node.kind === 'const' &&
+            !node.declare
+          ) {
+            const demotedInDecl: string[] = []
+            for (const decl of node.declarations) {
+              if (decl.id.type === 'Identifier' && toDemote.has(decl.id.name)) {
+                demotedInDecl.push(decl.id.name)
+              }
+            }
+            if (demotedInDecl.length) {
+              ctx.s.overwrite(
+                node.start! + startOffset,
+                node.start! + startOffset + 'const'.length,
+                'let',
+              )
+              for (const id of demotedInDecl) {
+                setupBindings[id] = BindingTypes.SETUP_LET
+                ctx.bindingMetadata[id] = BindingTypes.SETUP_LET
+                warnOnce(
+                  `\`v-model\` cannot update a \`const\` reactive binding \`${id}\`. ` +
+                    `The compiler has transformed it to \`let\` to make the update work.`,
+                )
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
   // 7. inject `useCssVars` calls
   if (
     sfc.cssVars.length &&
index 22ef37cf37f5ab98ab4081a4a893f74cf6fc599f..9c087b466916baf6c884eeebf6eb8150ae8beff3 100644 (file)
@@ -4,6 +4,7 @@ import {
   NodeTypes,
   type SimpleExpressionNode,
   type TemplateChildNode,
+  isSimpleIdentifier,
   parserOptions,
   walkIdentifiers,
 } from '@vue/compiler-dom'
@@ -19,16 +20,38 @@ export function isImportUsed(local: string, sfc: SFCDescriptor): boolean {
   return resolveTemplateUsedIdentifiers(sfc).has(local)
 }
 
-const templateUsageCheckCache = createCache<Set<string>>()
+const templateAnalysisCache = createCache<{
+  usedIds?: Set<string>
+  vModelIds: Set<string>
+}>()
+
+export function resolveTemplateVModelIdentifiers(
+  sfc: SFCDescriptor,
+): Set<string> {
+  return resolveTemplateAnalysisResult(sfc, false).vModelIds
+}
 
 function resolveTemplateUsedIdentifiers(sfc: SFCDescriptor): Set<string> {
+  return resolveTemplateAnalysisResult(sfc).usedIds!
+}
+
+function resolveTemplateAnalysisResult(
+  sfc: SFCDescriptor,
+  collectUsedIds = true,
+): {
+  usedIds?: Set<string>
+  vModelIds: Set<string>
+} {
   const { content, ast } = sfc.template!
-  const cached = templateUsageCheckCache.get(content)
-  if (cached) {
+  const cached = templateAnalysisCache.get(content)
+  if (cached && (!collectUsedIds || cached.usedIds)) {
     return cached
   }
 
-  const ids = new Set<string>()
+  // When `collectUsedIds` is false we skip the expensive identifier extraction
+  // and only collect `vModelIds`.
+  const ids = collectUsedIds ? new Set<string>() : undefined
+  const vModelIds = new Set<string>()
 
   ast!.children.forEach(walk)
 
@@ -41,31 +64,56 @@ function resolveTemplateUsedIdentifiers(sfc: SFCDescriptor): Set<string> {
           !parserOptions.isNativeTag!(tag) &&
           !parserOptions.isBuiltInComponent!(tag)
         ) {
-          ids.add(camelize(tag))
-          ids.add(capitalize(camelize(tag)))
+          if (ids) {
+            ids.add(camelize(tag))
+            ids.add(capitalize(camelize(tag)))
+          }
         }
         for (let i = 0; i < node.props.length; i++) {
           const prop = node.props[i]
           if (prop.type === NodeTypes.DIRECTIVE) {
-            if (!isBuiltInDirective(prop.name)) {
-              ids.add(`v${capitalize(camelize(prop.name))}`)
+            if (ids) {
+              if (!isBuiltInDirective(prop.name)) {
+                ids.add(`v${capitalize(camelize(prop.name))}`)
+              }
+            }
+
+            // collect v-model target identifiers (simple identifiers only)
+            if (prop.name === 'model') {
+              const exp = prop.exp
+              if (exp && exp.type === NodeTypes.SIMPLE_EXPRESSION) {
+                const expString = exp.content.trim()
+                if (
+                  isSimpleIdentifier(expString) &&
+                  expString !== 'undefined'
+                ) {
+                  vModelIds.add(expString)
+                }
+              }
             }
 
             // process dynamic directive arguments
-            if (prop.arg && !(prop.arg as SimpleExpressionNode).isStatic) {
+            if (
+              ids &&
+              prop.arg &&
+              !(prop.arg as SimpleExpressionNode).isStatic
+            ) {
               extractIdentifiers(ids, prop.arg)
             }
 
-            if (prop.name === 'for') {
-              extractIdentifiers(ids, prop.forParseResult!.source)
-            } else if (prop.exp) {
-              extractIdentifiers(ids, prop.exp)
-            } else if (prop.name === 'bind' && !prop.exp) {
-              // v-bind shorthand name as identifier
-              ids.add(camelize((prop.arg as SimpleExpressionNode).content))
+            if (ids) {
+              if (prop.name === 'for') {
+                extractIdentifiers(ids, prop.forParseResult!.source)
+              } else if (prop.exp) {
+                extractIdentifiers(ids, prop.exp)
+              } else if (prop.name === 'bind' && !prop.exp) {
+                // v-bind shorthand name as identifier
+                ids.add(camelize((prop.arg as SimpleExpressionNode).content))
+              }
             }
           }
           if (
+            ids &&
             prop.type === NodeTypes.ATTRIBUTE &&
             prop.name === 'ref' &&
             prop.value?.content
@@ -76,13 +124,14 @@ function resolveTemplateUsedIdentifiers(sfc: SFCDescriptor): Set<string> {
         node.children.forEach(walk)
         break
       case NodeTypes.INTERPOLATION:
-        extractIdentifiers(ids, node.content)
+        if (ids) extractIdentifiers(ids, node.content)
         break
     }
   }
 
-  templateUsageCheckCache.set(content, ids)
-  return ids
+  const result = { usedIds: ids, vModelIds }
+  templateAnalysisCache.set(content, result)
+  return result
 }
 
 function extractIdentifiers(ids: Set<string>, node: ExpressionNode) {