Browse Source

fix(runtime-vapor): validate static hydration targets in dev (#14835)

edison 4 weeks ago
parent
commit
2c5e940a41

+ 51 - 0
packages/runtime-vapor/__tests__/hydration.spec.ts

@@ -7388,6 +7388,57 @@ describe('mismatch handling', () => {
       expect(container.innerHTML).toBe(`<!--foo--><span>updated</span>`)
       expect(container.innerHTML).toBe(`<!--foo--><span>updated</span>`)
     })
     })
 
 
+    test('warns on static element tag mismatch', () => {
+      const container = document.createElement('div')
+      container.innerHTML = `<span>foo</span><span>after</span>`
+
+      hydrateNode(container.firstChild!, () => {
+        const n0 = template('<div>foo', false, true)() as HTMLElement
+        const n1 = template('<span>after', false, true)() as HTMLElement
+
+        expect(n0).toBe(container.firstChild)
+        expect(n1).toBe(container.lastChild)
+      })
+
+      expect(`Hydration node mismatch`).toHaveBeenWarned()
+    })
+
+    test('warns on static first-node type mismatches', () => {
+      const cases = [
+        { server: `foo<span>after</span>`, client: '<div>foo' },
+        { server: `<!--foo--><span>after</span>`, client: '<div>foo' },
+        { server: `<div>foo</div><span>after</span>`, client: 'foo' },
+      ]
+
+      for (const { server, client } of cases) {
+        const container = document.createElement('div')
+        container.innerHTML = server
+
+        hydrateNode(container.firstChild!, () => {
+          template(client, false, true)()
+          template('<span>after', false, true)()
+        })
+      }
+
+      expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(3)
+    })
+
+    test('does not warn static first-node mismatch when parent allows children mismatch', () => {
+      const container = document.createElement('div')
+      container.innerHTML = `<div data-allow-mismatch="children"><span>foo</span><span>after</span></div>`
+      const parent = container.firstChild as HTMLElement
+
+      hydrateNode(parent.firstChild!, () => {
+        const n0 = template('<div>foo', false, true)() as HTMLElement
+        const n1 = template('<span>after', false, true)() as HTMLElement
+
+        expect(n0).toBe(parent.firstChild)
+        expect(n1).toBe(parent.lastChild)
+      })
+
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
     test('multi-root static nodes', async () => {
     test('multi-root static nodes', async () => {
       const container = document.createElement('div')
       const container = document.createElement('div')
       container.innerHTML = `<!--[--><div>one</div><p>two</p><!--]--><span>after</span>`
       container.innerHTML = `<!--[--><div>one</div><p>two</p><!--]--><span>after</span>`

+ 51 - 15
packages/runtime-vapor/src/dom/hydration.ts

@@ -20,6 +20,8 @@ import {
 } from './node'
 } from './node'
 import { remove } from '../block'
 import { remove } from '../block'
 
 
+const START_TAG_RE = /^<([^\s/>]+)/
+
 export let isHydratingEnabled = false
 export let isHydratingEnabled = false
 
 
 export function setIsHydratingEnabled(value: boolean): void {
 export function setIsHydratingEnabled(value: boolean): void {
@@ -321,21 +323,7 @@ export function locateHydrationBoundaryClose(
 }
 }
 
 
 function handleMismatch(node: Node, template: string): Node {
 function handleMismatch(node: Node, template: string): Node {
-  if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
-    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-      warn(
-        `Hydration node mismatch:\n- rendered on server:`,
-        node,
-        node.nodeType === 3
-          ? `(text)`
-          : isComment(node, '[[')
-            ? `(start of block node)`
-            : ``,
-        `\n- expected on client:`,
-        template,
-      )
-    logMismatchError()
-  }
+  warnHydrationNodeMismatch(node, template)
 
 
   // fragment
   // fragment
   if (isComment(node, '[')) {
   if (isComment(node, '[')) {
@@ -373,6 +361,54 @@ function handleMismatch(node: Node, template: string): Node {
   return newNode
   return newNode
 }
 }
 
 
+export function validateHydrationTarget(node: Node, template: string): void {
+  let expectedType: number
+  if (template[0] !== '<') {
+    expectedType = 3
+  } else if (template[1] === '!') {
+    expectedType = 8
+  } else {
+    expectedType = 1
+  }
+
+  if (node.nodeType !== expectedType) {
+    warnHydrationNodeMismatch(node, template)
+    return
+  }
+
+  if (expectedType !== 1) {
+    return
+  }
+
+  const match = START_TAG_RE.exec(template)
+  const expectedTag = match && match[1]
+
+  if (
+    expectedTag &&
+    (node as Element).tagName.toLowerCase() !== expectedTag.toLowerCase()
+  ) {
+    warnHydrationNodeMismatch(node, template)
+  }
+}
+
+function warnHydrationNodeMismatch(node: Node, expected: unknown): void {
+  if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
+    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+      warn(
+        `Hydration node mismatch:\n- rendered on server:`,
+        node,
+        node.nodeType === 3
+          ? `(text)`
+          : isComment(node, '[[')
+            ? `(start of block node)`
+            : ``,
+        `\n- expected on client:`,
+        expected,
+      )
+    logMismatchError()
+  }
+}
+
 let hasLoggedMismatchError = false
 let hasLoggedMismatchError = false
 export const logMismatchError = (): void => {
 export const logMismatchError = (): void => {
   if (__TEST__ || hasLoggedMismatchError) {
   if (__TEST__ || hasLoggedMismatchError) {

+ 7 - 0
packages/runtime-vapor/src/dom/template.ts

@@ -4,6 +4,7 @@ import {
   currentHydrationNode,
   currentHydrationNode,
   isHydrating,
   isHydrating,
   resolveHydrationTarget,
   resolveHydrationTarget,
+  validateHydrationTarget,
 } from './hydration'
 } from './hydration'
 import { type Namespace, Namespaces } from '@vue/shared'
 import { type Namespace, Namespaces } from '@vue/shared'
 import { _child, createTextNode } from './node'
 import { _child, createTextNode } from './node'
@@ -27,6 +28,12 @@ export function template(
       // never mutates their DOM afterwards.
       // never mutates their DOM afterwards.
       if (isStatic) {
       if (isStatic) {
         adopted = resolveHydrationTarget(currentHydrationNode!)
         adopted = resolveHydrationTarget(currentHydrationNode!)
+        if (
+          (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+          html !== ''
+        ) {
+          validateHydrationTarget(adopted, html)
+        }
         node = adopted.cloneNode(true)
         node = adopted.cloneNode(true)
         advanceHydrationNode(adopted)
         advanceHydrationNode(adopted)
       } else {
       } else {