`).toHaveBeenWarned()
+ expect(`Hydration text content mismatch`).toHaveBeenWarned()
})
test('not enough children', () => {
@@ -1136,7 +1136,7 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toBe(
'
foobar
'
)
- expect(`Hydration children mismatch in
`).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('
foo
')
- expect(`Hydration children mismatch in
`).toHaveBeenWarned()
+ expect(`Hydration children mismatch`).toHaveBeenWarned()
})
test('complete mismatch', () => {
@@ -1219,5 +1219,57 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toBe('
')
expect(`Hydration node mismatch`).toHaveBeenWarned()
})
+
+ test('class mismatch', () => {
+ mountWithHydration(`
`, () =>
+ h('div', { class: ['foo', 'bar'] })
+ )
+ mountWithHydration(`
`, () =>
+ h('div', { class: { foo: true, bar: true } })
+ )
+ mountWithHydration(`
`, () =>
+ h('div', { class: 'foo bar' })
+ )
+ expect(`Hydration class mismatch`).not.toHaveBeenWarned()
+ mountWithHydration(`
`, () =>
+ h('div', { class: 'foo' })
+ )
+ expect(`Hydration class mismatch`).toHaveBeenWarned()
+ })
+
+ test('style mismatch', () => {
+ mountWithHydration(`
`, () =>
+ h('div', { style: { color: 'red' } })
+ )
+ mountWithHydration(`
`, () =>
+ h('div', { style: `color:red;` })
+ )
+ expect(`Hydration style mismatch`).not.toHaveBeenWarned()
+ mountWithHydration(`
`, () =>
+ h('div', { style: { color: 'green' } })
+ )
+ expect(`Hydration style mismatch`).toHaveBeenWarned()
+ })
+
+ test('attr mismatch', () => {
+ mountWithHydration(`
`, () => h('div', { id: 'foo' }))
+ mountWithHydration(`
`, () =>
+ h('div', { spellcheck: '' })
+ )
+ // boolean
+ mountWithHydration(`
`, () =>
+ h('select', { multiple: true })
+ )
+ mountWithHydration(`
`, () =>
+ h('select', { multiple: 'multiple' })
+ )
+ expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
+
+ mountWithHydration(`
`, () => h('div', { id: 'foo' }))
+ expect(`Hydration attribute mismatch`).toHaveBeenWarned()
+
+ mountWithHydration(`
`, () => h('div', { id: 'foo' }))
+ expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
+ })
})
})
diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts
index d79c09d3d..35ab85195 100644
--- a/packages/runtime-core/src/hydration.ts
+++ b/packages/runtime-core/src/hydration.ts
@@ -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,14 +358,86 @@ export function createHydrationFunctions(
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'created')
}
+
+ // handle appear transition
+ let needCallTransitionHooks = false
+ if (isTemplateNode(el)) {
+ needCallTransitionHooks =
+ needTransition(parentSuspense, transition) &&
+ parentComponent &&
+ parentComponent.vnode.props &&
+ parentComponent.vnode.props.appear
+
+ const content = (el as HTMLTemplateElement).content
+ .firstChild as Element
+
+ if (needCallTransitionHooks) {
+ transition!.beforeEnter(content)
+ }
+
+ // replace
node with inner children
+ replaceNode(content, el, parentComponent)
+ vnode.el = el = content
+ }
+
+ // children
+ if (
+ shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
+ // skip if element has innerHTML / textContent
+ !(props && (props.innerHTML || props.textContent))
+ ) {
+ let next = hydrateChildren(
+ el.firstChild,
+ vnode,
+ el,
+ parentComponent,
+ parentSuspense,
+ slotScopeIds,
+ optimized
+ )
+ let hasWarned = false
+ while (next) {
+ hasMismatch = true
+ if (__DEV__ && !hasWarned) {
+ warn(
+ `Hydration children mismatch on`,
+ el,
+ `\nServer rendered element contains more child nodes than client vdom.`
+ )
+ hasWarned = true
+ }
+ // The SSRed DOM contains more nodes than it should. Remove them.
+ const cur = next
+ next = next.nextSibling
+ remove(cur)
+ }
+ } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
+ if (el.textContent !== vnode.children) {
+ hasMismatch = true
+ __DEV__ &&
+ warn(
+ `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')) ||
@@ -384,37 +470,15 @@ export function createHydrationFunctions(
)
}
}
+
// vnode / directive hooks
let vnodeHooks: VNodeHook | null | undefined
if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
}
-
- // handle appear transition
- let needCallTransitionHooks = false
- if (isTemplateNode(el)) {
- needCallTransitionHooks =
- needTransition(parentSuspense, transition) &&
- parentComponent &&
- parentComponent.vnode.props &&
- parentComponent.vnode.props.appear
-
- const content = (el as HTMLTemplateElement).content
- .firstChild as Element
-
- if (needCallTransitionHooks) {
- transition!.beforeEnter(content)
- }
-
- // replace node with inner children
- replaceNode(content, el, parentComponent)
- vnode.el = el = content
- }
-
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
}
-
if (
(vnodeHooks = props && props.onVnodeMounted) ||
dirs ||
@@ -426,51 +490,8 @@ export function createHydrationFunctions(
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense)
}
- // children
- if (
- shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
- // skip if element has innerHTML / textContent
- !(props && (props.innerHTML || props.textContent))
- ) {
- let next = hydrateChildren(
- el.firstChild,
- vnode,
- el,
- parentComponent,
- parentSuspense,
- slotScopeIds,
- optimized
- )
- let hasWarned = false
- while (next) {
- hasMismatch = true
- if (__DEV__ && !hasWarned) {
- warn(
- `Hydration children mismatch in <${vnode.type as string}>: ` +
- `server rendered element contains more child nodes than client vdom.`
- )
- hasWarned = true
- }
- // The SSRed DOM contains more nodes than it should. Remove them.
- const cur = next
- next = next.nextSibling
- remove(cur)
- }
- } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
- if (el.textContent !== vnode.children) {
- 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}`
- )
- el.textContent = vnode.children as string
- }
- }
}
+
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
+}