`).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 d79c09d3d36..35ab851953a 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,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
+}