From 060199cfd4cf88ef49f08317f4decc6dadbabf6d Mon Sep 17 00:00:00 2001 From: daiwei Date: Wed, 4 Feb 2026 17:51:51 +0800 Subject: [PATCH] fix(v-model): preserve pre-hydration user input across native controls --- .../runtime-core/__tests__/hydration.spec.ts | 115 ++++++++++ packages/runtime-core/src/directives.ts | 3 + packages/runtime-core/src/hydration.ts | 7 +- packages/runtime-dom/src/directives/vModel.ts | 202 +++++++++++++----- 4 files changed, 274 insertions(+), 53 deletions(-) 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 = { // relies on its children //