Skip to content

Commit e24ff7d

Browse files
authored
fix(compiler-sfc): demote const reactive bindings used in v-model (#14214)
close #11265 close #11275
1 parent 69ce3c7 commit e24ff7d

8 files changed

Lines changed: 269 additions & 21 deletions

File tree

packages/compiler-core/__tests__/transforms/vModel.spec.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,5 +582,22 @@ describe('compiler: transform v-model', () => {
582582
}),
583583
)
584584
})
585+
586+
test('used on const binding', () => {
587+
const onError = vi.fn()
588+
parseWithVModel('<div v-model="c" />', {
589+
onError,
590+
bindingMetadata: {
591+
c: BindingTypes.LITERAL_CONST,
592+
},
593+
})
594+
595+
expect(onError).toHaveBeenCalledTimes(1)
596+
expect(onError).toHaveBeenCalledWith(
597+
expect.objectContaining({
598+
code: ErrorCodes.X_V_MODEL_ON_CONST,
599+
}),
600+
)
601+
})
585602
})
586603
})

packages/compiler-core/src/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export enum ErrorCodes {
8888
X_V_MODEL_MALFORMED_EXPRESSION,
8989
X_V_MODEL_ON_SCOPE_VARIABLE,
9090
X_V_MODEL_ON_PROPS,
91+
X_V_MODEL_ON_CONST,
9192
X_INVALID_EXPRESSION,
9293
X_KEEP_ALIVE_INVALID_CHILDREN,
9394

@@ -176,6 +177,7 @@ export const errorMessages: Record<ErrorCodes, string> = {
176177
[ErrorCodes.X_V_MODEL_MALFORMED_EXPRESSION]: `v-model value must be a valid JavaScript member expression.`,
177178
[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.`,
178179
[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.`,
180+
[ErrorCodes.X_V_MODEL_ON_CONST]: `v-model cannot be used on a const binding because it is not writable.`,
179181
[ErrorCodes.X_INVALID_EXPRESSION]: `Error parsing JavaScript expression: `,
180182
[ErrorCodes.X_KEEP_ALIVE_INVALID_CHILDREN]: `<KeepAlive> expects exactly one child component.`,
181183
[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.`,

packages/compiler-core/src/transforms/vModel.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ export const transformModel: DirectiveTransform = (dir, node, context) => {
4848
return createTransformProps()
4949
}
5050

51+
// const bindings are not writable.
52+
if (
53+
bindingType === BindingTypes.LITERAL_CONST ||
54+
bindingType === BindingTypes.SETUP_CONST
55+
) {
56+
context.onError(createCompilerError(ErrorCodes.X_V_MODEL_ON_CONST, exp.loc))
57+
return createTransformProps()
58+
}
59+
5160
const maybeRef =
5261
!__BROWSER__ &&
5362
context.inline &&

packages/compiler-dom/src/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export function createDOMCompilerError(
2121
}
2222

2323
export enum DOMErrorCodes {
24-
X_V_HTML_NO_EXPRESSION = 53 /* ErrorCodes.__EXTEND_POINT__ */,
24+
X_V_HTML_NO_EXPRESSION = 54 /* ErrorCodes.__EXTEND_POINT__ */,
2525
X_V_HTML_WITH_CHILDREN,
2626
X_V_TEXT_NO_EXPRESSION,
2727
X_V_TEXT_WITH_CHILDREN,

packages/compiler-sfc/__tests__/__snapshots__/compileScript.spec.ts.snap

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,44 @@ return { foo, bar, baz, y, z }
639639
}"
640640
`;
641641
642+
exports[`SFC compile <script setup> > demote const reactive binding to let when used in v-model (inlineTemplate) 1`] = `
643+
"import { unref as _unref, resolveComponent as _resolveComponent, isRef as _isRef, openBlock as _openBlock, createBlock as _createBlock } from "vue"
644+
645+
import { reactive } from 'vue'
646+
647+
export default {
648+
setup(__props) {
649+
650+
let name = reactive({ first: 'john', last: 'doe' })
651+
652+
return (_ctx, _cache) => {
653+
const _component_MyComponent = _resolveComponent("MyComponent")
654+
655+
return (_openBlock(), _createBlock(_component_MyComponent, {
656+
modelValue: _unref(name),
657+
"onUpdate:modelValue": _cache[0] || (_cache[0] = $event => (_isRef(name) ? (name).value = $event : name = $event))
658+
}, null, 8 /* PROPS */, ["modelValue"]))
659+
}
660+
}
661+
662+
}"
663+
`;
664+
665+
exports[`SFC compile <script setup> > demote const reactive binding to let when used in v-model 1`] = `
666+
"import { reactive } from 'vue'
667+
668+
export default {
669+
setup(__props, { expose: __expose }) {
670+
__expose();
671+
672+
let name = reactive({ first: 'john', last: 'doe' })
673+
674+
return { get name() { return name }, set name(v) { name = v }, reactive }
675+
}
676+
677+
}"
678+
`;
679+
642680
exports[`SFC compile <script setup> > errors > should allow defineProps/Emit() referencing imported binding 1`] = `
643681
"import { bar } from './bar'
644682

packages/compiler-sfc/__tests__/compileScript.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { vi } from 'vitest'
12
import { BindingTypes } from '@vue/compiler-core'
23
import {
34
assertCode,
@@ -7,6 +8,15 @@ import {
78
} from './utils'
89
import { type RawSourceMap, SourceMapConsumer } from 'source-map-js'
910

11+
vi.mock('../src/warn', () => ({
12+
warn: vi.fn(),
13+
warnOnce: vi.fn(),
14+
}))
15+
16+
import { warnOnce } from '../src/warn'
17+
18+
const warnOnceMock = vi.mocked(warnOnce)
19+
1020
describe('SFC compile <script setup>', () => {
1121
test('should compile JS syntax', () => {
1222
const { content } = compile(`
@@ -74,6 +84,77 @@ describe('SFC compile <script setup>', () => {
7484
assertCode(content)
7585
})
7686

87+
test('demote const reactive binding to let when used in v-model', () => {
88+
warnOnceMock.mockClear()
89+
const { content, bindings } = compile(`
90+
<script setup>
91+
import { reactive } from 'vue'
92+
const name = reactive({ first: 'john', last: 'doe' })
93+
</script>
94+
95+
<template>
96+
<MyComponent v-model="name" />
97+
</template>
98+
`)
99+
100+
expect(content).toMatch(
101+
`let name = reactive({ first: 'john', last: 'doe' })`,
102+
)
103+
expect(bindings!.name).toBe(BindingTypes.SETUP_LET)
104+
expect(warnOnceMock).toHaveBeenCalledTimes(1)
105+
expect(warnOnceMock).toHaveBeenCalledWith(
106+
expect.stringContaining(
107+
'`v-model` cannot update a `const` reactive binding',
108+
),
109+
)
110+
assertCode(content)
111+
})
112+
113+
test('demote const reactive binding to let when used in v-model (inlineTemplate)', () => {
114+
warnOnceMock.mockClear()
115+
const { content, bindings } = compile(
116+
`
117+
<script setup>
118+
import { reactive } from 'vue'
119+
const name = reactive({ first: 'john', last: 'doe' })
120+
</script>
121+
122+
<template>
123+
<MyComponent v-model="name" />
124+
</template>
125+
`,
126+
{ inlineTemplate: true },
127+
)
128+
129+
expect(content).toMatch(
130+
`let name = reactive({ first: 'john', last: 'doe' })`,
131+
)
132+
expect(bindings!.name).toBe(BindingTypes.SETUP_LET)
133+
expect(warnOnceMock).toHaveBeenCalledTimes(1)
134+
expect(warnOnceMock).toHaveBeenCalledWith(
135+
expect.stringContaining(
136+
'`v-model` cannot update a `const` reactive binding',
137+
),
138+
)
139+
assertCode(content)
140+
})
141+
142+
test('v-model should error on literal const bindings', () => {
143+
expect(() =>
144+
compile(
145+
`
146+
<script setup>
147+
const foo = 1
148+
</script>
149+
<template>
150+
<input v-model="foo" />
151+
</template>
152+
`,
153+
{ inlineTemplate: true },
154+
),
155+
).toThrow('v-model cannot be used on a const binding')
156+
})
157+
77158
describe('<script> and <script setup> co-usage', () => {
78159
test('script first', () => {
79160
const { content } = compile(`

packages/compiler-sfc/src/compileScript.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ import {
6464
isTS,
6565
} from './script/utils'
6666
import { analyzeScriptBindings } from './script/analyzeScriptBindings'
67-
import { isImportUsed } from './script/importUsageCheck'
67+
import {
68+
isImportUsed,
69+
resolveTemplateVModelIdentifiers,
70+
} from './script/importUsageCheck'
6871
import { processAwait } from './script/topLevelAwait'
6972

7073
export interface SFCScriptCompileOptions {
@@ -760,6 +763,55 @@ export function compileScript(
760763
ctx.bindingMetadata[key] = setupBindings[key]
761764
}
762765

766+
// #11265, https://github.com/vitejs/rolldown-vite/issues/432
767+
// 6.1 demote `const foo = reactive()` to `let` when used as v-model target.
768+
// In non-inline template compilation, v-model assigns via `$setup.foo = $event`,
769+
// which requires a SETUP_LET binding (getter + setter) to keep script state in sync.
770+
// In inline mode, it generates `foo = $event`, which also requires `let`.
771+
if (sfc.template && !sfc.template.src && sfc.template.ast) {
772+
const vModelIds = resolveTemplateVModelIdentifiers(sfc)
773+
if (vModelIds.size) {
774+
const toDemote = new Set<string>()
775+
for (const id of vModelIds) {
776+
if (setupBindings[id] === BindingTypes.SETUP_REACTIVE_CONST) {
777+
toDemote.add(id)
778+
}
779+
}
780+
781+
if (toDemote.size) {
782+
for (const node of scriptSetupAst.body) {
783+
if (
784+
node.type === 'VariableDeclaration' &&
785+
node.kind === 'const' &&
786+
!node.declare
787+
) {
788+
const demotedInDecl: string[] = []
789+
for (const decl of node.declarations) {
790+
if (decl.id.type === 'Identifier' && toDemote.has(decl.id.name)) {
791+
demotedInDecl.push(decl.id.name)
792+
}
793+
}
794+
if (demotedInDecl.length) {
795+
ctx.s.overwrite(
796+
node.start! + startOffset,
797+
node.start! + startOffset + 'const'.length,
798+
'let',
799+
)
800+
for (const id of demotedInDecl) {
801+
setupBindings[id] = BindingTypes.SETUP_LET
802+
ctx.bindingMetadata[id] = BindingTypes.SETUP_LET
803+
warnOnce(
804+
`\`v-model\` cannot update a \`const\` reactive binding \`${id}\`. ` +
805+
`The compiler has transformed it to \`let\` to make the update work.`,
806+
)
807+
}
808+
}
809+
}
810+
}
811+
}
812+
}
813+
}
814+
763815
// 7. inject `useCssVars` calls
764816
if (
765817
sfc.cssVars.length &&

0 commit comments

Comments
 (0)