Преглед изворни кода

fix(v-model): preserve pre-hydration user input across native controls

daiwei пре 2 месеци
родитељ
комит
060199cfd4

+ 115 - 0
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: `<div><input v-model="text"><span class="text">{{ text }}</span></div>`,
+    }
+
+    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: `<div><input type="checkbox" v-model="checked"><span class="checked">{{ checked }}</span></div>`,
+    }
+
+    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: `<div>
+        <input type="radio" value="a" v-model="picked">
+        <input type="radio" value="b" v-model="picked">
+        <span class="picked">{{ picked }}</span>
+      </div>`,
+    }
+
+    const container = document.createElement('div')
+    container.innerHTML = await renderToString(h(App))
+
+    const radios = container.querySelectorAll<HTMLInputElement>('input')
+    const a = radios[0]!
+    const b = radios[1]!
+    a.checked = false
+    b.checked = true
+
+    createSSRApp(App).mount(container)
+
+    expect(
+      container.querySelectorAll<HTMLInputElement>('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: `<div>
+        <select v-model="selected">
+          <option value="a">A</option>
+          <option value="b">B</option>
+        </select>
+        <span class="selected">{{ selected }}</span>
+      </div>`,
+    }
+
+    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(
       '<input type="checkbox" indeterminate>',

+ 3 - 0
packages/runtime-core/src/directives.ts

@@ -49,6 +49,7 @@ export type DirectiveHook<
   binding: DirectiveBinding<Value, Modifiers, Arg>,
   vnode: VNode<any, HostElement>,
   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()
     }

+ 4 - 3
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)
       }
     }

+ 152 - 50
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<T, Modifiers extends string = string> = 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<HTMLInputElement> = {
   // #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<HTMLInputElement> = {
 }
 
 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<HTMLInputElement> = {
-  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<HTMLInputElement> = {
 export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
   // <select multiple> value need to be deep traversed
   deep: true,
-  created(el, { value, modifiers: { number } }, vnode) {
+  created(el, { value, modifiers: { number } }, vnode, _prev, hydrating) {
+    el[hydratingKey] = hydrating
+    if (hydrating) {
+      el[hydrationSelectKey] = getSelectedValue(el, number, isSet(value))
+    }
     const isSetModel = isSet(value)
     addEventListener(el, 'change', () => {
-      const selectedVal = Array.prototype.filter
-        .call(el.options, (o: HTMLOptionElement) => o.selected)
-        .map((o: HTMLOptionElement) =>
-          number ? looseToNumber(getValue(o)) : getValue(o),
-        )
-      el[assignKey](
-        el.multiple
-          ? isSetModel
-            ? new Set(selectedVal)
-            : selectedVal
-          : selectedVal[0],
-      )
+      el[assignKey](getSelectedValue(el, number, isSetModel))
       el._assigning = true
       nextTick(() => {
         el._assigning = false
@@ -225,7 +250,23 @@ export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
   },
   // set value in mounted & updated because <select> relies on its children
   // <option>s.
-  mounted(el, { value }) {
+  mounted(el, { value, modifiers: { number } }) {
+    const hydrating = el[hydratingKey]
+    delete el[hydratingKey]
+    const hydrateValue = el[hydrationSelectKey]
+    if (hydrateValue !== undefined) {
+      delete el[hydrationSelectKey]
+    }
+    if (hydrating) {
+      const selectedValue =
+        hydrateValue !== undefined
+          ? hydrateValue
+          : getSelectedValue(el, number, isSet(value))
+      if (!isSelectValueEqual(selectedValue, value)) {
+        el[assignKey] && el[assignKey](selectedValue)
+        return
+      }
+    }
     setSelected(el, value)
   },
   beforeUpdate(el, _binding, vnode) {
@@ -275,6 +316,66 @@ function setSelected(el: HTMLSelectElement, value: any) {
   }
 }
 
+function getSelectedValue(
+  el: HTMLSelectElement,
+  number: boolean | undefined,
+  isSetModel: boolean,
+) {
+  const selectedVal = Array.prototype.filter
+    .call(el.options, (o: HTMLOptionElement) => o.selected)
+    .map((o: HTMLOptionElement) =>
+      number ? looseToNumber(getValue(o)) : getValue(o),
+    )
+  return el.multiple
+    ? isSetModel
+      ? new Set(selectedVal)
+      : selectedVal
+    : selectedVal[0]
+}
+
+function isSelectValueEqual(a: any, b: any) {
+  if (isSet(a) && isSet(b)) {
+    if (a.size !== b.size) return false
+    for (const value of a) {
+      if (!b.has(value)) {
+        return false
+      }
+    }
+    return true
+  }
+  return looseEqual(a, b)
+}
+
+function setCheckboxValue(
+  assign: AssignerFn,
+  modelValue: any,
+  elementValue: any,
+  checked: boolean,
+  el: HTMLInputElement & { _trueValue?: any; _falseValue?: any },
+) {
+  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))
+  }
+}
+
 // retrieve raw value set via :value bindings
 function getValue(el: HTMLOptionElement | HTMLInputElement) {
   return '_value' in el ? (el as any)._value : el.value
@@ -292,11 +393,11 @@ function getCheckboxValue(
 export const vModelDynamic: ObjectDirective<
   HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
 > = {
-  created(el, binding, vnode) {
-    callModelHook(el, binding, vnode, null, 'created')
+  created(el, binding, vnode, _prev, hydrating) {
+    callModelHook(el, binding, vnode, null, 'created', hydrating)
   },
-  mounted(el, binding, vnode) {
-    callModelHook(el, binding, vnode, null, 'mounted')
+  mounted(el, binding, vnode, _prev, hydrating) {
+    callModelHook(el, binding, vnode, null, 'mounted', hydrating)
   },
   beforeUpdate(el, binding, vnode, prevVNode) {
     callModelHook(el, binding, vnode, prevVNode, 'beforeUpdate')
@@ -330,13 +431,14 @@ function callModelHook(
   vnode: VNode,
   prevVNode: VNode | null,
   hook: keyof ObjectDirective,
+  hydrating?: boolean,
 ) {
   const modelToUse = resolveDynamicModel(
     el.tagName,
     vnode.props && vnode.props.type,
   )
   const fn = modelToUse[hook] as DirectiveHook
-  fn && fn(el, binding, vnode, prevVNode)
+  fn && fn(el, binding, vnode, prevVNode, hydrating)
 }
 
 // SSR vnode transforms, only used when user includes client-oriented render