import { transformRef } from './transforms/transformRef'
import { transformInterpolation } from './transforms/transformInterpolation'
import type { HackOptions } from './ir'
+import { transformVModel } from './transforms/vModel'
export type CompilerOptions = HackOptions<BaseCompilerOptions>
html: transformVHtml,
text: transformVText,
show: transformVShow,
+ model: transformVModel,
},
]
}
type RootIRNode,
type SetEventIRNode,
type SetHtmlIRNode,
+ type SetModelValueIRNode,
type SetPropIRNode,
type SetRefIRNode,
type SetTextIRNode,
return genSetHtml(oper, context)
case IRNodeTypes.SET_REF:
return genSetRef(oper, context)
+ case IRNodeTypes.SET_MODEL_VALUE:
+ return genSetModelValue(oper, context)
case IRNodeTypes.CREATE_TEXT_NODE:
return genCreateTextNode(oper, context)
case IRNodeTypes.INSERT_NODE:
)
}
+function genSetModelValue(oper: SetModelValueIRNode, context: CodegenContext) {
+ const { vaporHelper, push, newline, pushFnCall } = context
+
+ newline()
+ pushFnCall(
+ vaporHelper('on'),
+ // 1st arg: event name
+ () => push(`n${oper.element}`),
+ // 2nd arg: event name
+ () => {
+ if (isString(oper.key)) {
+ push(JSON.stringify(`update:${camelize(oper.key)}`))
+ } else {
+ push('`update:${')
+ genExpression(oper.key, context)
+ push('}`')
+ }
+ },
+ // 3rd arg: event handler
+ () => {
+ push((context.isTS ? `($event: any)` : `$event`) + ' => ((')
+ // TODO handle not a ref
+ genExpression(oper.value, context)
+ push(') = $event)')
+ },
+ )
+}
+
function genCreateTextNode(
oper: CreateTextNodeIRNode,
context: CodegenContext,
function genWithDirective(oper: WithDirectiveIRNode, context: CodegenContext) {
const { push, newline, pushFnCall, pushMulti, vaporHelper, bindingMetadata } =
context
- const { dir } = oper
+ const { dir, builtin } = oper
// TODO merge directive for the same node
newline()
pushMulti(['[', ']', ', '], () => {
if (dir.name === 'show') {
push(vaporHelper('vShow'))
+ } else if (builtin) {
+ push(vaporHelper(builtin))
} else {
const directiveReference = camelize(`v-${dir.name}`)
// TODO resolve directive
SET_EVENT,
SET_HTML,
SET_REF,
+ SET_MODEL_VALUE,
INSERT_NODE,
PREPEND_NODE,
value: IRExpression
}
+export interface SetModelValueIRNode extends BaseIRNode {
+ type: IRNodeTypes.SET_MODEL_VALUE
+ element: number
+ key: IRExpression
+ value: IRExpression
+ isComponent: boolean
+}
+
export interface CreateTextNodeIRNode extends BaseIRNode {
type: IRNodeTypes.CREATE_TEXT_NODE
id: number
type: IRNodeTypes.WITH_DIRECTIVE
element: number
dir: VaporDirectiveNode
+ builtin?: string
}
export type IRNode =
| SetEventIRNode
| SetHtmlIRNode
| SetRefIRNode
+ | SetModelValueIRNode
| CreateTextNodeIRNode
| InsertNodeIRNode
| PrependNodeIRNode
type SimpleExpressionNode,
createCompilerError,
createSimpleExpression,
-} from '@vue/compiler-core'
+} from '@vue/compiler-dom'
import { camelize, isReservedProp } from '@vue/shared'
import { IRNodeTypes } from '../ir'
import type { DirectiveTransform } from '../transform'
--- /dev/null
+import {
+ BindingTypes,
+ DOMErrorCodes,
+ ElementTypes,
+ ErrorCodes,
+ NodeTypes,
+ createCompilerError,
+ createDOMCompilerError,
+ findDir,
+ findProp,
+ hasDynamicKeyVBind,
+ isMemberExpression,
+ isStaticArgOf,
+} from '@vue/compiler-dom'
+import type { DirectiveTransform } from '../transform'
+import { IRNodeTypes } from '..'
+
+export const transformVModel: DirectiveTransform = (dir, node, context) => {
+ const { exp, arg, loc } = dir
+ if (!exp) {
+ context.options.onError(
+ createCompilerError(ErrorCodes.X_V_MODEL_NO_EXPRESSION, dir.loc),
+ )
+ return
+ }
+
+ // we assume v-model directives are always parsed
+ // (not artificially created by a transform)
+ const rawExp = exp.loc.source
+ const expString = exp.content
+
+ // in SFC <script setup> inline mode, the exp may have been transformed into
+ // _unref(exp)
+ const bindingType = context.options.bindingMetadata[rawExp]
+
+ // check props
+ if (
+ bindingType === BindingTypes.PROPS ||
+ bindingType === BindingTypes.PROPS_ALIASED
+ ) {
+ context.options.onError(
+ createCompilerError(ErrorCodes.X_V_MODEL_ON_PROPS, exp.loc),
+ )
+ return
+ }
+
+ const maybeRef =
+ !__BROWSER__ &&
+ context.options.inline &&
+ (bindingType === BindingTypes.SETUP_LET ||
+ bindingType === BindingTypes.SETUP_REF ||
+ bindingType === BindingTypes.SETUP_MAYBE_REF)
+
+ if (
+ !expString.trim() ||
+ (!isMemberExpression(expString, context.options) && !maybeRef)
+ ) {
+ context.options.onError(
+ createCompilerError(ErrorCodes.X_V_MODEL_MALFORMED_EXPRESSION, exp.loc),
+ )
+ return
+ }
+
+ const isComponent = node.tagType === ElementTypes.COMPONENT
+ let runtimeDirective: string | undefined
+
+ if (isComponent) {
+ if (dir.arg)
+ context.options.onError(
+ createDOMCompilerError(
+ DOMErrorCodes.X_V_MODEL_ARG_ON_ELEMENT,
+ dir.arg.loc,
+ ),
+ )
+ } else {
+ const { tag } = node
+ const isCustomElement = context.options.isCustomElement(tag)
+ runtimeDirective = 'vModelText'
+ if (
+ tag === 'input' ||
+ tag === 'textarea' ||
+ tag === 'select' ||
+ isCustomElement
+ ) {
+ if (tag === 'input' || isCustomElement) {
+ const type = findProp(node, 'type')
+ if (type) {
+ if (type.type === NodeTypes.DIRECTIVE) {
+ // :type="foo"
+ runtimeDirective = 'vModelDynamic'
+ } else if (type.value) {
+ switch (type.value.content) {
+ case 'radio':
+ runtimeDirective = 'vModelRadio'
+ break
+ case 'checkbox':
+ runtimeDirective = 'vModelCheckbox'
+ break
+ case 'file':
+ runtimeDirective = undefined
+ context.options.onError(
+ createDOMCompilerError(
+ DOMErrorCodes.X_V_MODEL_ON_FILE_INPUT_ELEMENT,
+ dir.loc,
+ ),
+ )
+ break
+ default:
+ // text type
+ __DEV__ && checkDuplicatedValue()
+ break
+ }
+ }
+ } else if (hasDynamicKeyVBind(node)) {
+ // element has bindings with dynamic keys, which can possibly contain
+ // "type".
+ runtimeDirective = 'vModelDynamic'
+ } else {
+ // text type
+ __DEV__ && checkDuplicatedValue()
+ }
+ } else if (tag === 'select') {
+ runtimeDirective = 'vModelSelect'
+ } else {
+ // textarea
+ __DEV__ && checkDuplicatedValue()
+ }
+ } else {
+ context.options.onError(
+ createDOMCompilerError(
+ DOMErrorCodes.X_V_MODEL_ON_INVALID_ELEMENT,
+ dir.loc,
+ ),
+ )
+ }
+ }
+
+ context.registerOperation({
+ type: IRNodeTypes.SET_MODEL_VALUE,
+ element: context.reference(),
+ key: (arg && arg.isStatic ? arg.content : arg) || 'modelValue',
+ value: exp,
+ isComponent,
+ loc: loc,
+ })
+
+ if (runtimeDirective)
+ context.registerOperation({
+ type: IRNodeTypes.WITH_DIRECTIVE,
+ element: context.reference(),
+ dir,
+ loc,
+ builtin: runtimeDirective,
+ })
+
+ function checkDuplicatedValue() {
+ const value = findDir(node, 'bind')
+ if (value && isStaticArgOf(value.arg, 'value')) {
+ context.options.onError(
+ createDOMCompilerError(
+ DOMErrorCodes.X_V_MODEL_UNNECESSARY_VALUE,
+ value.loc,
+ ),
+ )
+ }
+ }
+}
ElementTypes,
ErrorCodes,
createCompilerError,
-} from '@vue/compiler-core'
+} from '@vue/compiler-dom'
import type { DirectiveTransform } from '../transform'
import { IRNodeTypes, type KeyOverride, type SetEventIRNode } from '../ir'
import { resolveModifiers } from '@vue/compiler-dom'
type LifecycleHook<TFn = Function> = TFn[] | null
+export interface ElementMetadata {
+ props: Data
+}
+
export interface ComponentInternalInstance {
uid: number
container: ParentNode
/** directives */
dirs: Map<Node, DirectiveBinding[]>
+ metadata: WeakMap<Node, ElementMetadata>
// lifecycle
isMounted: boolean
setupState: EMPTY_OBJ,
dirs: new Map(),
+ metadata: new WeakMap(),
// lifecycle
isMounted: false,
export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
export interface DirectiveBinding<V = any, M extends string = string> {
- instance: ComponentInternalInstance | null
+ instance: ComponentInternalInstance
source?: () => V
value: V
oldValue: V | null
--- /dev/null
+import type { ComponentInternalInstance } from '../component'
+import type { ObjectDirective } from '../directive'
+import { on } from '../dom/on'
+import { invokeArrayFns, isArray, looseToNumber } from '@vue/shared'
+
+type AssignerFn = (value: any) => void
+
+function getModelAssigner(
+ el: Element,
+ instance: ComponentInternalInstance,
+): AssignerFn {
+ const metadata = instance.metadata.get(el)!
+ const fn: any = metadata.props['onUpdate:modelValue']
+ return isArray(fn) ? (value) => invokeArrayFns(fn, value) : fn
+}
+
+function onCompositionStart(e: Event) {
+ ;(e.target as any).composing = true
+}
+
+function onCompositionEnd(e: Event) {
+ const target = e.target as any
+ if (target.composing) {
+ target.composing = false
+ target.dispatchEvent(new Event('input'))
+ }
+}
+
+const assignKeyMap = new WeakMap<HTMLElement, AssignerFn>()
+
+// We are exporting the v-model runtime directly as vnode hooks so that it can
+// be tree-shaken in case v-model is never used.
+export const vModelText: ObjectDirective<
+ HTMLInputElement | HTMLTextAreaElement
+> = {
+ beforeMount(el, { instance, modifiers: { lazy, trim, number } = {} }) {
+ const assigner = getModelAssigner(el, instance)
+ assignKeyMap.set(el, assigner)
+
+ const castToNumber = number // || (vnode.props && vnode.props.type === 'number')
+ on(el, lazy ? 'change' : 'input', (e) => {
+ if ((e.target as any).composing) return
+ let domValue: string | number = el.value
+ if (trim) {
+ domValue = domValue.trim()
+ }
+ if (castToNumber) {
+ domValue = looseToNumber(domValue)
+ }
+ assigner(domValue)
+ })
+ if (trim) {
+ on(el, 'change', () => {
+ el.value = el.value.trim()
+ })
+ }
+ if (!lazy) {
+ on(el, 'compositionstart', onCompositionStart)
+ on(el, 'compositionend', onCompositionEnd)
+ // Safari < 10.2 & UIWebView doesn't fire compositionend when
+ // switching focus before confirming composition choice
+ // this also fixes the issue where some browsers e.g. iOS Chrome
+ // fires "change" instead of "input" on autocomplete.
+ on(el, 'change', onCompositionEnd)
+ }
+ },
+ // set value on mounted so it's after min/max for type="range"
+ mounted(el, { value }) {
+ el.value = value == null ? '' : value
+ },
+ beforeUpdate(
+ el,
+ { instance, value, modifiers: { lazy, trim, number } = {} },
+ ) {
+ assignKeyMap.set(el, getModelAssigner(el, instance))
+
+ // avoid clearing unresolved text. #2302
+ if ((el as any).composing) return
+
+ const elValue =
+ number || el.type === 'number' ? looseToNumber(el.value) : el.value
+ const newValue = value == null ? '' : value
+
+ if (elValue === newValue) {
+ return
+ }
+
+ // eslint-disable-next-line no-restricted-globals
+ if (document.activeElement === el && el.type !== 'range') {
+ if (lazy) {
+ return
+ }
+ if (trim && el.value.trim() === newValue) {
+ return
+ }
+ }
+
+ el.value = newValue
+ },
+}
export * from './dom/patchProp'
export * from './dom/templateRef'
+export * from './dom/on'
export function insert(block: Block, parent: Node, anchor: Node | null = null) {
// if (!isHydrating) {
import { getCurrentEffect, onEffectCleanup } from '@vue/reactivity'
+import { recordPropMetadata } from './patchProp'
+import { toHandlerKey } from '@vue/shared'
export function on(
el: HTMLElement,
event: string,
- handler: () => any,
+ handler: (...args: any) => any,
options?: AddEventListenerOptions,
) {
+ recordPropMetadata(el, toHandlerKey(event), handler)
el.addEventListener(event, handler, options)
if (getCurrentEffect()) {
onEffectCleanup(() => el.removeEventListener(event, handler, options))
normalizeClass,
normalizeStyle,
} from '@vue/shared'
+import { currentInstance } from '../component'
export function setClass(el: Element, oldVal: any, newVal: any) {
if ((newVal = normalizeClass(newVal)) !== oldVal && (newVal || oldVal)) {
+ recordPropMetadata(el, 'class', newVal)
el.className = newVal
}
}
export function setStyle(el: HTMLElement, oldVal: any, newVal: any) {
if ((newVal = normalizeStyle(newVal)) !== oldVal && (newVal || oldVal)) {
+ recordPropMetadata(el, 'style', newVal)
if (typeof newVal === 'string') {
el.style.cssText = newVal
} else {
export function setAttr(el: Element, key: string, oldVal: any, newVal: any) {
if (newVal !== oldVal) {
+ recordPropMetadata(el, key, newVal)
if (newVal != null) {
el.setAttribute(key, newVal)
} else {
export function setDOMProp(el: any, key: string, oldVal: any, newVal: any) {
// TODO special checks
if (newVal !== oldVal) {
+ recordPropMetadata(el, key, newVal)
el[key] = newVal
}
}
}
}
+export function recordPropMetadata(el: Node, key: string, value: any) {
+ if (currentInstance) {
+ let metadata = currentInstance.metadata.get(el)
+ if (!metadata) {
+ currentInstance.metadata.set(el, (metadata = { props: {} }))
+ }
+ metadata.props[key] = value
+ }
+}
+
// TODO copied from runtime-dom
const isNativeOn = (key: string) =>
key.charCodeAt(0) === 111 /* o */ &&
export { nextTick } from './scheduler'
export { getCurrentInstance, type ComponentInternalInstance } from './component'
-export * from './on'
export * from './render'
export * from './renderWatch'
export * from './template'
export * from './apiWatch'
export * from './directive'
export * from './dom'
-export * from './directives/vShow'
export * from './apiLifecycle'
export * from './if'
+
+export * from './directives/vShow'
+export * from './directives/vModel'
<script setup lang="ts">
-import { onMounted, ref } from 'vue/vapor'
+import { ref } from 'vue/vapor'
interface Task {
title: string
}
const tasks = ref<Task[]>([])
const value = ref('hello')
-const inputRef = ref<HTMLInputElement>()
function handleAdd() {
tasks.value.push({
// TODO: clear input
value.value = ''
}
-
-onMounted(() => {
- console.log('onMounted')
- console.log(inputRef.value)
-})
</script>
<template>
{{ tasks[3]?.title }}
</li>
<li>
- <input
- type="text"
- :ref="el => (inputRef = el)"
- :value="value"
- @input="evt => (value = evt.target.value)"
- />
+ <input type="text" v-model="value" />
<button @click="handleAdd">Add</button>
</li>
</ul>