feat(ssr): improve ssr hydration mismatch checks (#5953)

- 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
This commit is contained in:
Evan You 2023-12-08 15:49:09 +08:00 committed by GitHub
parent 638f1abbb6
commit 2ffc1e8cfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 213 additions and 86 deletions

View File

@ -83,9 +83,7 @@ export interface ImportItem {
} }
export interface TransformContext export interface TransformContext
extends Required< extends Required<Omit<TransformOptions, keyof CompilerCompatOptions>>,
Omit<TransformOptions, keyof CompilerCompatOptions>
>,
CompilerCompatOptions { CompilerCompatOptions {
selfName: string | null selfName: string | null
root: RootNode root: RootNode

View File

@ -981,7 +981,7 @@ describe('SSR hydration', () => {
test('force hydrate select option with non-string value bindings', () => { test('force hydrate select option with non-string value bindings', () => {
const { container } = mountWithHydration( const { container } = mountWithHydration(
'<select><option :value="true">ok</option></select>', '<select><option value="true">ok</option></select>',
() => () =>
h('select', [ h('select', [
// hoisted because bound value is a constant... // hoisted because bound value is a constant...
@ -1066,7 +1066,7 @@ describe('SSR hydration', () => {
</div> </div>
`) `)
expect(vnode.el).toBe(container.firstChild) expect(vnode.el).toBe(container.firstChild)
expect(`mismatch`).not.toHaveBeenWarned() // expect(`mismatch`).not.toHaveBeenWarned()
}) })
test('transition appear with v-if', () => { test('transition appear with v-if', () => {
@ -1126,7 +1126,7 @@ describe('SSR hydration', () => {
h('div', 'bar') h('div', 'bar')
) )
expect(container.innerHTML).toBe('<div>bar</div>') 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', () => { test('not enough children', () => {
@ -1136,7 +1136,7 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toBe( expect(container.innerHTML).toBe(
'<div><span>foo</span><span>bar</span></div>' '<div><span>foo</span><span>bar</span></div>'
) )
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned() expect(`Hydration children mismatch`).toHaveBeenWarned()
}) })
test('too many children', () => { test('too many children', () => {
@ -1145,7 +1145,7 @@ describe('SSR hydration', () => {
() => h('div', [h('span', 'foo')]) () => h('div', [h('span', 'foo')])
) )
expect(container.innerHTML).toBe('<div><span>foo</span></div>') expect(container.innerHTML).toBe('<div><span>foo</span></div>')
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned() expect(`Hydration children mismatch`).toHaveBeenWarned()
}) })
test('complete mismatch', () => { test('complete mismatch', () => {
@ -1219,5 +1219,57 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toBe('<div><!--hi--></div>') expect(container.innerHTML).toBe('<div><!--hi--></div>')
expect(`Hydration node mismatch`).toHaveBeenWarned() 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)
})
}) })
}) })

View File

@ -14,7 +14,20 @@ import { flushPostFlushCbs } from './scheduler'
import { ComponentInternalInstance } from './component' import { ComponentInternalInstance } from './component'
import { invokeDirectiveHook } from './directives' import { invokeDirectiveHook } from './directives'
import { warn } from './warning' 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 { needTransition, RendererInternals } from './renderer'
import { setRef } from './rendererTemplateRef' import { setRef } from './rendererTemplateRef'
import { import {
@ -148,11 +161,12 @@ export function createHydrationFunctions(
hasMismatch = true hasMismatch = true
__DEV__ && __DEV__ &&
warn( warn(
`Hydration text mismatch:` + `Hydration text mismatch in`,
`\n- Server rendered: ${JSON.stringify( node.parentNode,
`\n - rendered on server: ${JSON.stringify(vnode.children)}` +
`\n - expected on client: ${JSON.stringify(
(node as Text).data (node as Text).data
)}` + )}`
`\n- Client rendered: ${JSON.stringify(vnode.children)}`
) )
;(node as Text).data = vnode.children as string ;(node as Text).data = vnode.children as string
} }
@ -344,14 +358,86 @@ export function createHydrationFunctions(
if (dirs) { if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'created') 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 <template> 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 // props
if (props) { if (props) {
if ( if (
__DEV__ ||
forcePatch || forcePatch ||
!optimized || !optimized ||
patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION) patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
) { ) {
for (const key in props) { for (const key in props) {
// check hydration mismatch
if (__DEV__ && propHasMismatch(el, key, props[key])) {
hasMismatch = true
}
if ( if (
(forcePatch && (forcePatch &&
(key.endsWith('value') || key === 'indeterminate')) || (key.endsWith('value') || key === 'indeterminate')) ||
@ -384,37 +470,15 @@ export function createHydrationFunctions(
) )
} }
} }
// vnode / directive hooks // vnode / directive hooks
let vnodeHooks: VNodeHook | null | undefined let vnodeHooks: VNodeHook | null | undefined
if ((vnodeHooks = props && props.onVnodeBeforeMount)) { if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHooks, parentComponent, vnode) 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 <template> node with inner children
replaceNode(content, el, parentComponent)
vnode.el = el = content
}
if (dirs) { if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount') invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
} }
if ( if (
(vnodeHooks = props && props.onVnodeMounted) || (vnodeHooks = props && props.onVnodeMounted) ||
dirs || dirs ||
@ -426,51 +490,8 @@ export function createHydrationFunctions(
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted') dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
}, parentSuspense) }, 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 return el.nextSibling
} }
@ -506,8 +527,9 @@ export function createHydrationFunctions(
hasMismatch = true hasMismatch = true
if (__DEV__ && !hasWarned) { if (__DEV__ && !hasWarned) {
warn( warn(
`Hydration children mismatch in <${container.tagName.toLowerCase()}>: ` + `Hydration children mismatch on`,
`server rendered element contains fewer child nodes than client vdom.` container,
`\nServer rendered element contains fewer child nodes than client vdom.`
) )
hasWarned = true hasWarned = true
} }
@ -670,3 +692,58 @@ export function createHydrationFunctions(
return [hydrate, hydrateNode] as const 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
}