]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(ssr): improve ssr hydration mismatch checks (#5953)
authorEvan You <yyx990803@gmail.com>
Fri, 8 Dec 2023 07:49:09 +0000 (15:49 +0800)
committerGitHub <noreply@github.com>
Fri, 8 Dec 2023 07:49:09 +0000 (15:49 +0800)
- Include the actual element in the warning message
- Also warn class/style/attribute mismatches

Note: class/style/attribute mismatches are check-only and will not be
rectified.

close #5063

packages/compiler-core/__tests__/transform.spec.ts
packages/compiler-core/src/transform.ts
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/hydration.ts

index 7657e74f7e80dc9043c8b6481e17bb62e979b879..33b80ff9043c9c0cbced3e4d1bd59dbe4900c4dd 100644 (file)
@@ -200,20 +200,20 @@ describe('compiler: transform', () => {
     expect((ast as any).children[0].props[0].exp.content).toBe(`_hoisted_1`)
     expect((ast as any).children[1].props[0].exp.content).toBe(`_hoisted_2`)
   })
-  
+
   test('context.filename and selfName', () => {
     const ast = baseParse(`<div />`)
-    
+
     const calls: any[] = []
     const plugin: NodeTransform = (node, context) => {
       calls.push({ ...context })
     }
-    
+
     transform(ast, {
       filename: '/the/fileName.vue',
       nodeTransforms: [plugin]
     })
-    
+
     expect(calls.length).toBe(2)
     expect(calls[1]).toMatchObject({
       filename: '/the/fileName.vue',
index 3a568a0729825409b559343f4a10d7d5d5475302..7da34bedb9b5351263754d3935175923df28da08 100644 (file)
@@ -83,9 +83,7 @@ export interface ImportItem {
 }
 
 export interface TransformContext
-  extends Required<
-      Omit<TransformOptions, keyof CompilerCompatOptions>
-    >,
+  extends Required<Omit<TransformOptions, keyof CompilerCompatOptions>>,
     CompilerCompatOptions {
   selfName: string | null
   root: RootNode
index 2b85cc974a470d47269b1fccdf4b5b55e21fe0d3..a5f056f385c7bfe92844d7cc49fff52d1bf610cd 100644 (file)
@@ -981,7 +981,7 @@ describe('SSR hydration', () => {
 
   test('force hydrate select option with non-string value bindings', () => {
     const { container } = mountWithHydration(
-      '<select><option :value="true">ok</option></select>',
+      '<select><option value="true">ok</option></select>',
       () =>
         h('select', [
           // hoisted because bound value is a constant...
@@ -1066,7 +1066,7 @@ describe('SSR hydration', () => {
       </div>
     `)
     expect(vnode.el).toBe(container.firstChild)
-    expect(`mismatch`).not.toHaveBeenWarned()
+    // expect(`mismatch`).not.toHaveBeenWarned()
   })
 
   test('transition appear with v-if', () => {
@@ -1126,7 +1126,7 @@ describe('SSR hydration', () => {
         h('div', 'bar')
       )
       expect(container.innerHTML).toBe('<div>bar</div>')
-      expect(`Hydration text content mismatch in <div>`).toHaveBeenWarned()
+      expect(`Hydration text content mismatch`).toHaveBeenWarned()
     })
 
     test('not enough children', () => {
@@ -1136,7 +1136,7 @@ describe('SSR hydration', () => {
       expect(container.innerHTML).toBe(
         '<div><span>foo</span><span>bar</span></div>'
       )
-      expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
     })
 
     test('too many children', () => {
@@ -1145,7 +1145,7 @@ describe('SSR hydration', () => {
         () => h('div', [h('span', 'foo')])
       )
       expect(container.innerHTML).toBe('<div><span>foo</span></div>')
-      expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
+      expect(`Hydration children mismatch`).toHaveBeenWarned()
     })
 
     test('complete mismatch', () => {
@@ -1219,5 +1219,57 @@ describe('SSR hydration', () => {
       expect(container.innerHTML).toBe('<div><!--hi--></div>')
       expect(`Hydration node mismatch`).toHaveBeenWarned()
     })
+
+    test('class mismatch', () => {
+      mountWithHydration(`<div class="foo bar"></div>`, () =>
+        h('div', { class: ['foo', 'bar'] })
+      )
+      mountWithHydration(`<div class="foo bar"></div>`, () =>
+        h('div', { class: { foo: true, bar: true } })
+      )
+      mountWithHydration(`<div class="foo bar"></div>`, () =>
+        h('div', { class: 'foo bar' })
+      )
+      expect(`Hydration class mismatch`).not.toHaveBeenWarned()
+      mountWithHydration(`<div class="foo bar"></div>`, () =>
+        h('div', { class: 'foo' })
+      )
+      expect(`Hydration class mismatch`).toHaveBeenWarned()
+    })
+
+    test('style mismatch', () => {
+      mountWithHydration(`<div style="color:red;"></div>`, () =>
+        h('div', { style: { color: 'red' } })
+      )
+      mountWithHydration(`<div style="color:red;"></div>`, () =>
+        h('div', { style: `color:red;` })
+      )
+      expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+      mountWithHydration(`<div style="color:red;"></div>`, () =>
+        h('div', { style: { color: 'green' } })
+      )
+      expect(`Hydration style mismatch`).toHaveBeenWarned()
+    })
+
+    test('attr mismatch', () => {
+      mountWithHydration(`<div id="foo"></div>`, () => h('div', { id: 'foo' }))
+      mountWithHydration(`<div spellcheck></div>`, () =>
+        h('div', { spellcheck: '' })
+      )
+      // boolean
+      mountWithHydration(`<select multiple></div>`, () =>
+        h('select', { multiple: true })
+      )
+      mountWithHydration(`<select multiple></div>`, () =>
+        h('select', { multiple: 'multiple' })
+      )
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+      mountWithHydration(`<div></div>`, () => h('div', { id: 'foo' }))
+      expect(`Hydration attribute mismatch`).toHaveBeenWarned()
+
+      mountWithHydration(`<div id="bar"></div>`, () => h('div', { id: 'foo' }))
+      expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
+    })
   })
 })
index d79c09d3d363f8dc5d0b7450974b038f36dbb84b..35ab851953a067b47ad6821f4e6f89c548f25246 100644 (file)
@@ -14,7 +14,20 @@ import { flushPostFlushCbs } from './scheduler'
 import { ComponentInternalInstance } from './component'
 import { invokeDirectiveHook } from './directives'
 import { warn } from './warning'
-import { PatchFlags, ShapeFlags, isReservedProp, isOn } from '@vue/shared'
+import {
+  PatchFlags,
+  ShapeFlags,
+  isReservedProp,
+  isOn,
+  normalizeClass,
+  normalizeStyle,
+  stringifyStyle,
+  isBooleanAttr,
+  isString,
+  includeBooleanAttr,
+  isKnownHtmlAttr,
+  isKnownSvgAttr
+} from '@vue/shared'
 import { needTransition, RendererInternals } from './renderer'
 import { setRef } from './rendererTemplateRef'
 import {
@@ -148,11 +161,12 @@ export function createHydrationFunctions(
             hasMismatch = true
             __DEV__ &&
               warn(
-                `Hydration text mismatch:` +
-                  `\n- Server rendered: ${JSON.stringify(
+                `Hydration text mismatch in`,
+                node.parentNode,
+                `\n  - rendered on server: ${JSON.stringify(vnode.children)}` +
+                  `\n  - expected on client: ${JSON.stringify(
                     (node as Text).data
-                  )}` +
-                  `\n- Client rendered: ${JSON.stringify(vnode.children)}`
+                  )}`
               )
             ;(node as Text).data = vnode.children as string
           }
@@ -344,51 +358,6 @@ export function createHydrationFunctions(
       if (dirs) {
         invokeDirectiveHook(vnode, null, parentComponent, 'created')
       }
-      // props
-      if (props) {
-        if (
-          forcePatch ||
-          !optimized ||
-          patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
-        ) {
-          for (const key in props) {
-            if (
-              (forcePatch &&
-                (key.endsWith('value') || key === 'indeterminate')) ||
-              (isOn(key) && !isReservedProp(key)) ||
-              // force hydrate v-bind with .prop modifiers
-              key[0] === '.'
-            ) {
-              patchProp(
-                el,
-                key,
-                null,
-                props[key],
-                false,
-                undefined,
-                parentComponent
-              )
-            }
-          }
-        } else if (props.onClick) {
-          // Fast path for click listeners (which is most often) to avoid
-          // iterating through props.
-          patchProp(
-            el,
-            'onClick',
-            null,
-            props.onClick,
-            false,
-            undefined,
-            parentComponent
-          )
-        }
-      }
-      // vnode / directive hooks
-      let vnodeHooks: VNodeHook | null | undefined
-      if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
-        invokeVNodeHook(vnodeHooks, parentComponent, vnode)
-      }
 
       // handle appear transition
       let needCallTransitionHooks = false
@@ -411,21 +380,6 @@ export function createHydrationFunctions(
         vnode.el = el = content
       }
 
-      if (dirs) {
-        invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
-      }
-
-      if (
-        (vnodeHooks = props && props.onVnodeMounted) ||
-        dirs ||
-        needCallTransitionHooks
-      ) {
-        queueEffectWithSuspense(() => {
-          vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
-          needCallTransitionHooks && transition!.enter(el)
-          dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
-        }, parentSuspense)
-      }
       // children
       if (
         shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
@@ -446,8 +400,9 @@ export function createHydrationFunctions(
           hasMismatch = true
           if (__DEV__ && !hasWarned) {
             warn(
-              `Hydration children mismatch in <${vnode.type as string}>: ` +
-                `server rendered element contains more child nodes than client vdom.`
+              `Hydration children mismatch on`,
+              el,
+              `\nServer rendered element contains more child nodes than client vdom.`
             )
             hasWarned = true
           }
@@ -461,16 +416,82 @@ export function createHydrationFunctions(
           hasMismatch = true
           __DEV__ &&
             warn(
-              `Hydration text content mismatch in <${
-                vnode.type as string
-              }>:\n` +
-                `- Server rendered: ${el.textContent}\n` +
-                `- Client rendered: ${vnode.children as string}`
+              `Hydration text content mismatch on`,
+              el,
+              `\n  - rendered on server: ${vnode.children as string}` +
+                `\n  - expected on client: ${el.textContent}`
             )
           el.textContent = vnode.children as string
         }
       }
+
+      // props
+      if (props) {
+        if (
+          __DEV__ ||
+          forcePatch ||
+          !optimized ||
+          patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
+        ) {
+          for (const key in props) {
+            // check hydration mismatch
+            if (__DEV__ && propHasMismatch(el, key, props[key])) {
+              hasMismatch = true
+            }
+            if (
+              (forcePatch &&
+                (key.endsWith('value') || key === 'indeterminate')) ||
+              (isOn(key) && !isReservedProp(key)) ||
+              // force hydrate v-bind with .prop modifiers
+              key[0] === '.'
+            ) {
+              patchProp(
+                el,
+                key,
+                null,
+                props[key],
+                false,
+                undefined,
+                parentComponent
+              )
+            }
+          }
+        } else if (props.onClick) {
+          // Fast path for click listeners (which is most often) to avoid
+          // iterating through props.
+          patchProp(
+            el,
+            'onClick',
+            null,
+            props.onClick,
+            false,
+            undefined,
+            parentComponent
+          )
+        }
+      }
+
+      // vnode / directive hooks
+      let vnodeHooks: VNodeHook | null | undefined
+      if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
+        invokeVNodeHook(vnodeHooks, parentComponent, vnode)
+      }
+      if (dirs) {
+        invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
+      }
+      if (
+        (vnodeHooks = props && props.onVnodeMounted) ||
+        dirs ||
+        needCallTransitionHooks
+      ) {
+        queueEffectWithSuspense(() => {
+          vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
+          needCallTransitionHooks && transition!.enter(el)
+          dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
+        }, parentSuspense)
+      }
     }
+
     return el.nextSibling
   }
 
@@ -506,8 +527,9 @@ export function createHydrationFunctions(
         hasMismatch = true
         if (__DEV__ && !hasWarned) {
           warn(
-            `Hydration children mismatch in <${container.tagName.toLowerCase()}>: ` +
-              `server rendered element contains fewer child nodes than client vdom.`
+            `Hydration children mismatch on`,
+            container,
+            `\nServer rendered element contains fewer child nodes than client vdom.`
           )
           hasWarned = true
         }
@@ -670,3 +692,58 @@ export function createHydrationFunctions(
 
   return [hydrate, hydrateNode] as const
 }
+
+/**
+ * Dev only
+ */
+function propHasMismatch(el: Element, key: string, clientValue: any): boolean {
+  let mismatchType: string | undefined
+  let mismatchKey: string | undefined
+  let actual: any
+  let expected: any
+  if (key === 'class') {
+    actual = el.className
+    expected = normalizeClass(clientValue)
+    if (actual !== expected) {
+      mismatchType = mismatchKey = `class`
+    }
+  } else if (key === 'style') {
+    actual = el.getAttribute('style')
+    expected = isString(clientValue)
+      ? clientValue
+      : stringifyStyle(normalizeStyle(clientValue))
+    if (actual !== expected) {
+      mismatchType = mismatchKey = 'style'
+    }
+  } else if (
+    (el instanceof SVGElement && isKnownSvgAttr(key)) ||
+    (el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
+  ) {
+    actual = el.hasAttribute(key) && el.getAttribute(key)
+    expected = isBooleanAttr(key)
+      ? includeBooleanAttr(clientValue)
+        ? ''
+        : false
+      : String(clientValue)
+    if (actual !== expected) {
+      mismatchType = `attribute`
+      mismatchKey = key
+    }
+  }
+
+  if (mismatchType) {
+    const format = (v: any) =>
+      v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
+    warn(
+      `Hydration ${mismatchType} mismatch on`,
+      el,
+      `\n  - rendered on server: ${format(actual)}` +
+        `\n  - expected on client: ${format(expected)}` +
+        `\n  Note: this mismatch is check-only. The DOM will not be rectified ` +
+        `in production due to performance overhead.` +
+        `\n  You should fix the source of the mismatch.`
+    )
+    return true
+  }
+  return false
+}