Skip to content

Commit 5ad12ab

Browse files
committed
feat(ssr): improve ssr hydration mismatch checks
- 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
1 parent e8ceac7 commit 5ad12ab

File tree

2 files changed

+185
-54
lines changed

2 files changed

+185
-54
lines changed

packages/runtime-core/__tests__/hydration.spec.ts

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -902,7 +902,7 @@ describe('SSR hydration', () => {
902902

903903
test('force hydrate select option with non-string value bindings', () => {
904904
const { container } = mountWithHydration(
905-
'<select><option :value="true">ok</option></select>',
905+
'<select><option value="true">ok</option></select>',
906906
() =>
907907
h('select', [
908908
// hoisted because bound value is a constant...
@@ -924,7 +924,7 @@ describe('SSR hydration', () => {
924924
h('div', 'bar')
925925
)
926926
expect(container.innerHTML).toBe('<div>bar</div>')
927-
expect(`Hydration text content mismatch in <div>`).toHaveBeenWarned()
927+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
928928
})
929929

930930
test('not enough children', () => {
@@ -934,7 +934,7 @@ describe('SSR hydration', () => {
934934
expect(container.innerHTML).toBe(
935935
'<div><span>foo</span><span>bar</span></div>'
936936
)
937-
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
937+
expect(`Hydration children mismatch`).toHaveBeenWarned()
938938
})
939939

940940
test('too many children', () => {
@@ -943,7 +943,7 @@ describe('SSR hydration', () => {
943943
() => h('div', [h('span', 'foo')])
944944
)
945945
expect(container.innerHTML).toBe('<div><span>foo</span></div>')
946-
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
946+
expect(`Hydration children mismatch`).toHaveBeenWarned()
947947
})
948948

949949
test('complete mismatch', () => {
@@ -1001,5 +1001,57 @@ describe('SSR hydration', () => {
10011001
expect(teleportContainer.innerHTML).toBe(`<span>value</span>`)
10021002
expect(`Hydration children mismatch`).toHaveBeenWarned()
10031003
})
1004+
1005+
test('class mismatch', () => {
1006+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1007+
h('div', { class: ['foo', 'bar'] })
1008+
)
1009+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1010+
h('div', { class: { foo: true, bar: true } })
1011+
)
1012+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1013+
h('div', { class: 'foo bar' })
1014+
)
1015+
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
1016+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1017+
h('div', { class: 'foo' })
1018+
)
1019+
expect(`Hydration class mismatch`).toHaveBeenWarned()
1020+
})
1021+
1022+
test('style mismatch', () => {
1023+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1024+
h('div', { style: { color: 'red' } })
1025+
)
1026+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1027+
h('div', { style: `color:red;` })
1028+
)
1029+
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
1030+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1031+
h('div', { style: { color: 'green' } })
1032+
)
1033+
expect(`Hydration style mismatch`).toHaveBeenWarned()
1034+
})
1035+
1036+
test('attr mismatch', () => {
1037+
mountWithHydration(`<div id="foo"></div>`, () => h('div', { id: 'foo' }))
1038+
mountWithHydration(`<div spellcheck></div>`, () =>
1039+
h('div', { spellcheck: '' })
1040+
)
1041+
// boolean
1042+
mountWithHydration(`<select multiple></div>`, () =>
1043+
h('select', { multiple: true })
1044+
)
1045+
mountWithHydration(`<select multiple></div>`, () =>
1046+
h('select', { multiple: 'multiple' })
1047+
)
1048+
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
1049+
1050+
mountWithHydration(`<div></div>`, () => h('div', { id: 'foo' }))
1051+
expect(`Hydration attribute mismatch`).toHaveBeenWarned()
1052+
1053+
mountWithHydration(`<div id="bar"></div>`, () => h('div', { id: 'foo' }))
1054+
expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
1055+
})
10041056
})
10051057
})

packages/runtime-core/src/hydration.ts

Lines changed: 129 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,20 @@ import { flushPostFlushCbs } from './scheduler'
1414
import { ComponentInternalInstance } from './component'
1515
import { invokeDirectiveHook } from './directives'
1616
import { warn } from './warning'
17-
import { PatchFlags, ShapeFlags, isReservedProp, isOn } from '@vue/shared'
17+
import {
18+
PatchFlags,
19+
ShapeFlags,
20+
isReservedProp,
21+
isOn,
22+
normalizeClass,
23+
normalizeStyle,
24+
stringifyStyle,
25+
isBooleanAttr,
26+
isString,
27+
includeBooleanAttr,
28+
isKnownHtmlAttr,
29+
isKnownSvgAttr
30+
} from '@vue/shared'
1831
import { RendererInternals } from './renderer'
1932
import { setRef } from './rendererTemplateRef'
2033
import {
@@ -116,9 +129,12 @@ export function createHydrationFunctions(
116129
hasMismatch = true
117130
__DEV__ &&
118131
warn(
119-
`Hydration text mismatch:` +
120-
`\n- Client: ${JSON.stringify((node as Text).data)}` +
121-
`\n- Server: ${JSON.stringify(vnode.children)}`
132+
`Hydration text mismatch in`,
133+
node.parentNode,
134+
`\n - rendered on server: ${JSON.stringify(vnode.children)}` +
135+
`\n - expected on client: ${JSON.stringify(
136+
(node as Text).data
137+
)}`
122138
)
123139
;(node as Text).data = vnode.children as string
124140
}
@@ -292,14 +308,65 @@ export function createHydrationFunctions(
292308
if (dirs) {
293309
invokeDirectiveHook(vnode, null, parentComponent, 'created')
294310
}
311+
312+
// children
313+
if (
314+
shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
315+
// skip if element has innerHTML / textContent
316+
!(props && (props.innerHTML || props.textContent))
317+
) {
318+
let next = hydrateChildren(
319+
el.firstChild,
320+
vnode,
321+
el,
322+
parentComponent,
323+
parentSuspense,
324+
slotScopeIds,
325+
optimized
326+
)
327+
let hasWarned = false
328+
while (next) {
329+
hasMismatch = true
330+
if (__DEV__ && !hasWarned) {
331+
warn(
332+
`Hydration children mismatch on`,
333+
el,
334+
`\nServer rendered element contains more child nodes than client vdom.`
335+
)
336+
hasWarned = true
337+
}
338+
// The SSRed DOM contains more nodes than it should. Remove them.
339+
const cur = next
340+
next = next.nextSibling
341+
remove(cur)
342+
}
343+
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
344+
if (el.textContent !== vnode.children) {
345+
hasMismatch = true
346+
__DEV__ &&
347+
warn(
348+
`Hydration text content mismatch on`,
349+
el,
350+
`\n - rendered on server: ${vnode.children as string}` +
351+
`\n - expected on client: ${el.textContent}`
352+
)
353+
el.textContent = vnode.children as string
354+
}
355+
}
356+
295357
// props
296358
if (props) {
297359
if (
360+
__DEV__ ||
298361
forcePatchValue ||
299362
!optimized ||
300363
patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.HYDRATE_EVENTS)
301364
) {
302365
for (const key in props) {
366+
// check hydration mismatch
367+
if (__DEV__ && propHasMismatch(el, key, props[key])) {
368+
hasMismatch = true
369+
}
303370
if (
304371
(forcePatchValue && key.endsWith('value')) ||
305372
(isOn(key) && !isReservedProp(key))
@@ -343,50 +410,6 @@ export function createHydrationFunctions(
343410
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
344411
}, parentSuspense)
345412
}
346-
// children
347-
if (
348-
shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
349-
// skip if element has innerHTML / textContent
350-
!(props && (props.innerHTML || props.textContent))
351-
) {
352-
let next = hydrateChildren(
353-
el.firstChild,
354-
vnode,
355-
el,
356-
parentComponent,
357-
parentSuspense,
358-
slotScopeIds,
359-
optimized
360-
)
361-
let hasWarned = false
362-
while (next) {
363-
hasMismatch = true
364-
if (__DEV__ && !hasWarned) {
365-
warn(
366-
`Hydration children mismatch in <${vnode.type as string}>: ` +
367-
`server rendered element contains more child nodes than client vdom.`
368-
)
369-
hasWarned = true
370-
}
371-
// The SSRed DOM contains more nodes than it should. Remove them.
372-
const cur = next
373-
next = next.nextSibling
374-
remove(cur)
375-
}
376-
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
377-
if (el.textContent !== vnode.children) {
378-
hasMismatch = true
379-
__DEV__ &&
380-
warn(
381-
`Hydration text content mismatch in <${
382-
vnode.type as string
383-
}>:\n` +
384-
`- Client: ${el.textContent}\n` +
385-
`- Server: ${vnode.children as string}`
386-
)
387-
el.textContent = vnode.children as string
388-
}
389-
}
390413
}
391414
return el.nextSibling
392415
}
@@ -423,8 +446,9 @@ export function createHydrationFunctions(
423446
hasMismatch = true
424447
if (__DEV__ && !hasWarned) {
425448
warn(
426-
`Hydration children mismatch in <${container.tagName.toLowerCase()}>: ` +
427-
`server rendered element contains fewer child nodes than client vdom.`
449+
`Hydration children mismatch on`,
450+
container,
451+
`\nServer rendered element contains fewer child nodes than client vdom.`
428452
)
429453
hasWarned = true
430454
}
@@ -554,3 +578,58 @@ export function createHydrationFunctions(
554578

555579
return [hydrate, hydrateNode] as const
556580
}
581+
582+
/**
583+
* Dev only
584+
*/
585+
function propHasMismatch(el: Element, key: string, clientValue: any): boolean {
586+
let mismatchType: string | undefined
587+
let mismatchKey: string | undefined
588+
let actual: any
589+
let expected: any
590+
if (key === 'class') {
591+
actual = el.className
592+
expected = normalizeClass(clientValue)
593+
if (actual !== expected) {
594+
mismatchType = mismatchKey = `class`
595+
}
596+
} else if (key === 'style') {
597+
actual = el.getAttribute('style')
598+
expected = isString(clientValue)
599+
? clientValue
600+
: stringifyStyle(normalizeStyle(clientValue))
601+
if (actual !== expected) {
602+
mismatchType = mismatchKey = 'style'
603+
}
604+
} else if (
605+
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
606+
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
607+
) {
608+
actual = el.hasAttribute(key) && el.getAttribute(key)
609+
expected = isBooleanAttr(key)
610+
? includeBooleanAttr(clientValue)
611+
? ''
612+
: false
613+
: String(clientValue)
614+
if (actual !== expected) {
615+
mismatchType = `attribute`
616+
mismatchKey = key
617+
}
618+
}
619+
620+
if (mismatchType) {
621+
const format = (v: any) =>
622+
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
623+
warn(
624+
`Hydration ${mismatchType} mismatch on`,
625+
el,
626+
`\n - rendered on server: ${format(actual)}` +
627+
`\n - expected on client: ${format(expected)}` +
628+
`\n Note: this mismatch is check-only. The DOM will not be rectified ` +
629+
`in production due to performance overhead.` +
630+
`\n You should fix the source of the mismatch.`
631+
)
632+
return true
633+
}
634+
return false
635+
}

0 commit comments

Comments
 (0)