Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/compiler-core/__tests__/transforms/vModel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
})
})
})
2 changes: 2 additions & 0 deletions packages/compiler-core/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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.`,
Expand Down
9 changes: 9 additions & 0 deletions packages/compiler-core/src/transforms/vModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler-dom/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
81 changes: 81 additions & 0 deletions packages/compiler-sfc/__tests__/compileScript.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { vi } from 'vitest'
import { BindingTypes } from '@vue/compiler-core'
import {
assertCode,
Expand All @@ -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(`
Expand Down Expand Up @@ -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(`
Expand Down
54 changes: 53 additions & 1 deletion packages/compiler-sfc/src/compileScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 &&
Expand Down
Loading
Loading