]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(hydration): support suppressing hydration mismatch via data-allow-mismatch
authorEvan You <evan@vuejs.org>
Thu, 25 Jul 2024 03:22:27 +0000 (11:22 +0800)
committerEvan You <evan@vuejs.org>
Thu, 25 Jul 2024 03:22:27 +0000 (11:22 +0800)
packages/runtime-core/__tests__/hydration.spec.ts
packages/runtime-core/src/hydration.ts

index ed1e228c4cf000ae10b55bd28cbb0f3d5b966923..7024df03c009b3377cb06ffa8772c07d5bab46fb 100644 (file)
@@ -1824,4 +1824,136 @@ describe('SSR hydration', () => {
       expect(`Hydration style mismatch`).not.toHaveBeenWarned()
     })
   })
+
+  describe('data-allow-mismatch', () => {
+    test('element text content', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="text">foo</div>`,
+        () => h('div', 'bar'),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="text">bar</div>',
+      )
+      expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('not enough children', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"></div>`,
+        () => h('div', [h('span', 'foo'), h('span', 'bar')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>',
+      )
+      expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('too many children', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
+        () => h('div', [h('span', 'foo')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><span>foo</span></div>',
+      )
+      expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('complete mismatch', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
+        () => h('div', [h('div', 'foo'), h('p', 'bar')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><div>foo</div><p>bar</p></div>',
+      )
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('fragment mismatch removal', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
+        () => h('div', [h('span', 'replaced')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><span>replaced</span></div>',
+      )
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('fragment not enough children', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
+        () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
+      )
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('fragment too many children', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
+        () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
+      )
+      // fragment ends early and attempts to hydrate the extra <div>bar</div>
+      // as 2nd fragment child.
+      expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
+      // excessive children removal
+      expect(`Hydration children mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('comment mismatch (element)', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children"><span></span></div>`,
+        () => h('div', [createCommentVNode('hi')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><!--hi--></div>',
+      )
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('comment mismatch (text)', () => {
+      const { container } = mountWithHydration(
+        `<div data-allow-mismatch="children">foobar</div>`,
+        () => h('div', [createCommentVNode('hi')]),
+      )
+      expect(container.innerHTML).toBe(
+        '<div data-allow-mismatch="children"><!--hi--></div>',
+      )
+      expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('class mismatch', () => {
+      mountWithHydration(
+        `<div class="foo bar" data-allow-mismatch="class"></div>`,
+        () => h('div', { class: 'foo' }),
+      )
+      expect(`Hydration class mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('style mismatch', () => {
+      mountWithHydration(
+        `<div style="color:red;" data-allow-mismatch="style"></div>`,
+        () => h('div', { style: { color: 'green' } }),
+      )
+      expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+    })
+
+    test('attr mismatch', () => {
+      mountWithHydration(`<div data-allow-mismatch="attribute"></div>`, () =>
+        h('div', { id: 'foo' }),
+      )
+      mountWithHydration(
+        `<div id="bar" data-allow-mismatch="attribute"></div>`,
+        () => h('div', { id: 'foo' }),
+      )
+      expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+    })
+  })
 })
index cfb7703cee58010cde7a933de29a16644e833256..e79a9cede3d8b4835d393e5242dfcf30d2cd6f62 100644 (file)
@@ -405,18 +405,20 @@ export function createHydrationFunctions(
         )
         let hasWarned = false
         while (next) {
-          if (
-            (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-            !hasWarned
-          ) {
-            warn(
-              `Hydration children mismatch on`,
-              el,
-              `\nServer rendered element contains more child nodes than client vdom.`,
-            )
-            hasWarned = true
+          if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
+            if (
+              (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+              !hasWarned
+            ) {
+              warn(
+                `Hydration children mismatch on`,
+                el,
+                `\nServer rendered element contains more child nodes than client vdom.`,
+              )
+              hasWarned = true
+            }
+            logMismatchError()
           }
-          logMismatchError()
 
           // The SSRed DOM contains more nodes than it should. Remove them.
           const cur = next
@@ -425,14 +427,16 @@ export function createHydrationFunctions(
         }
       } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
         if (el.textContent !== vnode.children) {
-          ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-            warn(
-              `Hydration text content mismatch on`,
-              el,
-              `\n  - rendered on server: ${el.textContent}` +
-                `\n  - expected on client: ${vnode.children as string}`,
-            )
-          logMismatchError()
+          if (!isMismatchAllowed(el, MismatchTypes.TEXT)) {
+            ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+              warn(
+                `Hydration text content mismatch on`,
+                el,
+                `\n  - rendered on server: ${el.textContent}` +
+                  `\n  - expected on client: ${vnode.children as string}`,
+              )
+            logMismatchError()
+          }
 
           el.textContent = vnode.children as string
         }
@@ -562,18 +566,20 @@ export function createHydrationFunctions(
         // because server rendered HTML won't contain a text node
         insert((vnode.el = createText('')), container)
       } else {
-        if (
-          (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-          !hasWarned
-        ) {
-          warn(
-            `Hydration children mismatch on`,
-            container,
-            `\nServer rendered element contains fewer child nodes than client vdom.`,
-          )
-          hasWarned = true
+        if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
+          if (
+            (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+            !hasWarned
+          ) {
+            warn(
+              `Hydration children mismatch on`,
+              container,
+              `\nServer rendered element contains fewer child nodes than client vdom.`,
+            )
+            hasWarned = true
+          }
+          logMismatchError()
         }
-        logMismatchError()
 
         // the SSRed DOM didn't contain enough nodes. Mount the missing ones.
         patch(
@@ -637,19 +643,21 @@ export function createHydrationFunctions(
     slotScopeIds: string[] | null,
     isFragment: boolean,
   ): Node | null => {
-    ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
-      warn(
-        `Hydration node mismatch:\n- rendered on server:`,
-        node,
-        node.nodeType === DOMNodeTypes.TEXT
-          ? `(text)`
-          : isComment(node) && node.data === '['
-            ? `(start of fragment)`
-            : ``,
-        `\n- expected on client:`,
-        vnode.type,
-      )
-    logMismatchError()
+    if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
+      ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
+        warn(
+          `Hydration node mismatch:\n- rendered on server:`,
+          node,
+          node.nodeType === DOMNodeTypes.TEXT
+            ? `(text)`
+            : isComment(node) && node.data === '['
+              ? `(start of fragment)`
+              : ``,
+          `\n- expected on client:`,
+          vnode.type,
+        )
+      logMismatchError()
+    }
 
     vnode.el = null
 
@@ -747,7 +755,7 @@ function propHasMismatch(
   vnode: VNode,
   instance: ComponentInternalInstance | null,
 ): boolean {
-  let mismatchType: string | undefined
+  let mismatchType: MismatchTypes | undefined
   let mismatchKey: string | undefined
   let actual: string | boolean | null | undefined
   let expected: string | boolean | null | undefined
@@ -757,7 +765,8 @@ function propHasMismatch(
     actual = el.getAttribute('class')
     expected = normalizeClass(clientValue)
     if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) {
-      mismatchType = mismatchKey = `class`
+      mismatchType = MismatchTypes.CLASS
+      mismatchKey = `class`
     }
   } else if (key === 'style') {
     // style might be in different order, but that doesn't affect cascade
@@ -782,7 +791,8 @@ function propHasMismatch(
     }
 
     if (!isMapEqual(actualMap, expectedMap)) {
-      mismatchType = mismatchKey = 'style'
+      mismatchType = MismatchTypes.STYLE
+      mismatchKey = 'style'
     }
   } else if (
     (el instanceof SVGElement && isKnownSvgAttr(key)) ||
@@ -808,15 +818,15 @@ function propHasMismatch(
         : false
     }
     if (actual !== expected) {
-      mismatchType = `attribute`
+      mismatchType = MismatchTypes.ATTRIBUTE
       mismatchKey = key
     }
   }
 
-  if (mismatchType) {
+  if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
     const format = (v: any) =>
       v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
-    const preSegment = `Hydration ${mismatchType} mismatch on`
+    const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`
     const postSegment =
       `\n  - rendered on server: ${format(actual)}` +
       `\n  - expected on client: ${format(expected)}` +
@@ -898,3 +908,48 @@ function resolveCssVars(
     resolveCssVars(instance.parent, instance.vnode, expectedMap)
   }
 }
+
+const allowMismatchAttr = 'data-allow-mismatch'
+
+enum MismatchTypes {
+  TEXT = 0,
+  CHILDREN = 1,
+  CLASS = 2,
+  STYLE = 3,
+  ATTRIBUTE = 4,
+}
+
+const MismatchTypeString: Record<MismatchTypes, string> = {
+  [MismatchTypes.TEXT]: 'text',
+  [MismatchTypes.CHILDREN]: 'children',
+  [MismatchTypes.CLASS]: 'class',
+  [MismatchTypes.STYLE]: 'style',
+  [MismatchTypes.ATTRIBUTE]: 'attribute',
+} as const
+
+function isMismatchAllowed(
+  el: Element | null,
+  allowedType: MismatchTypes,
+): boolean {
+  if (
+    allowedType === MismatchTypes.TEXT ||
+    allowedType === MismatchTypes.CHILDREN
+  ) {
+    while (el && !el.hasAttribute(allowMismatchAttr)) {
+      el = el.parentElement
+    }
+  }
+  const allowedAttr = el && el.getAttribute(allowMismatchAttr)
+  if (allowedAttr == null) {
+    return false
+  } else if (allowedAttr === '') {
+    return true
+  } else {
+    const list = allowedAttr.split(',')
+    // text is a subset of children
+    if (allowedType === MismatchTypes.TEXT && list.includes('children')) {
+      return true
+    }
+    return allowedAttr.split(',').includes(MismatchTypeString[allowedType])
+  }
+}