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:
parent
638f1abbb6
commit
2ffc1e8cfd
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue