diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts
index 7017c0414f4..1b568b96448 100644
--- a/packages/runtime-core/__tests__/hydration.spec.ts
+++ b/packages/runtime-core/__tests__/hydration.spec.ts
@@ -1551,6 +1551,121 @@ describe('SSR hydration', () => {
expect((container.firstChild as any)._trueValue).toBe(true)
})
+ test('preserves pre-hydration user input value for text v-model', async () => {
+ const App = {
+ data() {
+ return {
+ text: 'test value',
+ }
+ },
+ template: `
{{ text }}
`,
+ }
+
+ const container = document.createElement('div')
+ container.innerHTML = await renderToString(h(App))
+
+ const input = container.querySelector('input') as HTMLInputElement
+ input.value = 'edited before hydration'
+
+ createSSRApp(App).mount(container)
+
+ expect((container.querySelector('input') as HTMLInputElement).value).toBe(
+ 'edited before hydration',
+ )
+ await nextTick()
+ expect(container.querySelector('.text')!.textContent).toBe(
+ 'edited before hydration',
+ )
+ })
+
+ test('preserves pre-hydration checkbox v-model state', async () => {
+ const App = {
+ data() {
+ return {
+ checked: false,
+ }
+ },
+ template: `{{ checked }}
`,
+ }
+
+ const container = document.createElement('div')
+ container.innerHTML = await renderToString(h(App))
+
+ const input = container.querySelector('input') as HTMLInputElement
+ input.checked = true
+
+ createSSRApp(App).mount(container)
+
+ expect((container.querySelector('input') as HTMLInputElement).checked).toBe(
+ true,
+ )
+ await nextTick()
+ expect(container.querySelector('.checked')!.textContent).toBe('true')
+ })
+
+ test('preserves pre-hydration radio v-model state', async () => {
+ const App = {
+ data() {
+ return {
+ picked: 'a',
+ }
+ },
+ template: `
+
+
+ {{ picked }}
+
`,
+ }
+
+ const container = document.createElement('div')
+ container.innerHTML = await renderToString(h(App))
+
+ const radios = container.querySelectorAll('input')
+ const a = radios[0]!
+ const b = radios[1]!
+ a.checked = false
+ b.checked = true
+
+ createSSRApp(App).mount(container)
+
+ expect(
+ container.querySelectorAll('input')[1].checked,
+ ).toBe(true)
+ await nextTick()
+ expect(container.querySelector('.picked')!.textContent).toBe('b')
+ })
+
+ test('preserves pre-hydration select v-model value', async () => {
+ const App = {
+ data() {
+ return {
+ selected: 'a',
+ }
+ },
+ template: `
+
+ {{ selected }}
+
`,
+ }
+
+ const container = document.createElement('div')
+ container.innerHTML = await renderToString(h(App))
+
+ const select = container.querySelector('select') as HTMLSelectElement
+ select.selectedIndex = 1
+
+ createSSRApp(App).mount(container)
+
+ expect((container.querySelector('select') as HTMLSelectElement).value).toBe(
+ 'b',
+ )
+ await nextTick()
+ expect(container.querySelector('.selected')!.textContent).toBe('b')
+ })
+
test('force hydrate checkbox with indeterminate', () => {
const { container } = mountWithHydration(
'',
diff --git a/packages/runtime-core/src/directives.ts b/packages/runtime-core/src/directives.ts
index 36ff26f20f3..ed5d5e319f5 100644
--- a/packages/runtime-core/src/directives.ts
+++ b/packages/runtime-core/src/directives.ts
@@ -49,6 +49,7 @@ export type DirectiveHook<
binding: DirectiveBinding,
vnode: VNode,
prevVNode: Prev,
+ isHydrating?: boolean,
) => void
export type SSRDirectiveHook<
@@ -172,6 +173,7 @@ export function invokeDirectiveHook(
prevVNode: VNode | null,
instance: ComponentInternalInstance | null,
name: keyof ObjectDirective,
+ isHydrating = false,
): void {
const bindings = vnode.dirs!
const oldBindings = prevVNode && prevVNode.dirs!
@@ -193,6 +195,7 @@ export function invokeDirectiveHook(
binding,
vnode,
prevVNode,
+ isHydrating,
])
resetTracking()
}
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index a687d28d380..7c292d25a60 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -383,7 +383,7 @@ export function createHydrationFunctions(
// #5405 in dev, always hydrate children for HMR
if (__DEV__ || forcePatch || patchFlag !== PatchFlags.CACHED) {
if (dirs) {
- invokeDirectiveHook(vnode, null, parentComponent, 'created')
+ invokeDirectiveHook(vnode, null, parentComponent, 'created', true)
}
// handle appear transition
@@ -537,7 +537,7 @@ export function createHydrationFunctions(
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
}
if (dirs) {
- invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
+ invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount', true)
}
if (
(vnodeHooks = props && props.onVnodeMounted) ||
@@ -547,7 +547,8 @@ export function createHydrationFunctions(
queueEffectWithSuspense(() => {
vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
needCallTransitionHooks && transition!.enter(el)
- dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
+ dirs &&
+ invokeDirectiveHook(vnode, null, parentComponent, 'mounted', true)
}, parentSuspense)
}
}
diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts
index 3b446e320c5..bbc9d33cda2 100644
--- a/packages/runtime-dom/src/directives/vModel.ts
+++ b/packages/runtime-dom/src/directives/vModel.ts
@@ -38,9 +38,18 @@ function onCompositionEnd(e: Event) {
}
const assignKey: unique symbol = Symbol('_assign')
+const hydratingKey: unique symbol = Symbol('_hydrating')
+const hydrationValueKey: unique symbol = Symbol('_hydrateValue')
+const hydrationSelectKey: unique symbol = Symbol('_hydrateSelect')
type ModelDirective = ObjectDirective<
- T & { [assignKey]: AssignerFn; _assigning?: boolean },
+ T & {
+ [assignKey]: AssignerFn
+ _assigning?: boolean
+ [hydratingKey]?: boolean
+ [hydrationValueKey]?: string
+ [hydrationSelectKey]?: any
+ },
any,
Modifiers
>
@@ -57,7 +66,11 @@ export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement,
'trim' | 'number' | 'lazy'
> = {
- created(el, { modifiers: { lazy, trim, number } }, vnode) {
+ created(el, { modifiers: { lazy, trim, number } }, vnode, _prev, hydrating) {
+ el[hydratingKey] = hydrating
+ if (hydrating) {
+ el[hydrationValueKey] = el.value
+ }
el[assignKey] = getModelAssigner(vnode)
const castToNumber =
number || (vnode.props && vnode.props.type === 'number')
@@ -81,8 +94,24 @@ export const vModelText: ModelDirective<
}
},
// set value on mounted so it's after min/max for type="range"
- mounted(el, { value }) {
- el.value = value == null ? '' : value
+ mounted(el, { value, modifiers: { trim, number } }, vnode) {
+ const newValue = value == null ? '' : value
+ const hydrating = el[hydratingKey]
+ delete el[hydratingKey]
+ const hydrateValue = el[hydrationValueKey]
+ if (hydrateValue !== undefined) {
+ delete el[hydrationValueKey]
+ }
+ // Users may edit the value before hydration. Preserve that value
+ // and sync it back to the model instead of overriding it.
+ if (hydrating && hydrateValue !== undefined && hydrateValue !== newValue) {
+ const castToNumber =
+ number || (vnode.props && vnode.props.type === 'number')
+ el[assignKey] &&
+ el[assignKey](castValue(hydrateValue, trim, castToNumber))
+ return
+ }
+ el.value = newValue
},
beforeUpdate(
el,
@@ -119,38 +148,23 @@ export const vModelText: ModelDirective<
export const vModelCheckbox: ModelDirective = {
// #4096 array checkboxes need to be deep traversed
deep: true,
- created(el, _, vnode) {
+ created(el, _, vnode, _prev, hydrating) {
+ el[hydratingKey] = hydrating
el[assignKey] = getModelAssigner(vnode)
addEventListener(el, 'change', () => {
const modelValue = (el as any)._modelValue
const elementValue = getValue(el)
const checked = el.checked
const assign = el[assignKey]
- if (isArray(modelValue)) {
- const index = looseIndexOf(modelValue, elementValue)
- const found = index !== -1
- if (checked && !found) {
- assign(modelValue.concat(elementValue))
- } else if (!checked && found) {
- const filtered = [...modelValue]
- filtered.splice(index, 1)
- assign(filtered)
- }
- } else if (isSet(modelValue)) {
- const cloned = new Set(modelValue)
- if (checked) {
- cloned.add(elementValue)
- } else {
- cloned.delete(elementValue)
- }
- assign(cloned)
- } else {
- assign(getCheckboxValue(el, checked))
- }
+ setCheckboxValue(assign, modelValue, elementValue, checked, el)
})
},
// set initial checked on mount to wait for true-value/false-value
- mounted: setChecked,
+ mounted(el, binding, vnode) {
+ const hydrating = el[hydratingKey]
+ delete el[hydratingKey]
+ setChecked(el, binding, vnode, hydrating)
+ },
beforeUpdate(el, binding, vnode) {
el[assignKey] = getModelAssigner(vnode)
setChecked(el, binding, vnode)
@@ -158,13 +172,14 @@ export const vModelCheckbox: ModelDirective = {
}
function setChecked(
- el: HTMLInputElement,
+ el: HTMLInputElement & { [assignKey]?: AssignerFn; _modelValue?: any },
{ value, oldValue }: DirectiveBinding,
vnode: VNode,
+ hydrating?: boolean,
) {
// store the v-model value on the element so it can be accessed by the
// change listener.
- ;(el as any)._modelValue = value
+ el._modelValue = value
let checked: boolean
if (isArray(value)) {
@@ -176,6 +191,14 @@ function setChecked(
checked = looseEqual(value, getCheckboxValue(el, true))
}
+ if (hydrating && el.checked !== checked) {
+ const assign = el[assignKey]
+ if (assign) {
+ setCheckboxValue(assign, value, getValue(el), el.checked, el)
+ return
+ }
+ }
+
// Only update if the checked state has changed
if (el.checked !== checked) {
el.checked = checked
@@ -183,9 +206,18 @@ function setChecked(
}
export const vModelRadio: ModelDirective = {
- created(el, { value }, vnode) {
- el.checked = looseEqual(value, vnode.props!.value)
+ created(el, { value }, vnode, _prev, hydrating) {
+ el[hydratingKey] = hydrating
el[assignKey] = getModelAssigner(vnode)
+ const checked = looseEqual(value, vnode.props!.value)
+ if (hydrating && el.checked !== checked) {
+ if (el.checked) {
+ el[assignKey](vnode.props!.value)
+ }
+ } else {
+ el.checked = checked
+ }
+ delete el[hydratingKey]
addEventListener(el, 'change', () => {
el[assignKey](getValue(el))
})
@@ -201,21 +233,14 @@ export const vModelRadio: ModelDirective = {
export const vModelSelect: ModelDirective = {
//