Skip to content

Commit 2ffc1e8

Browse files
authoredDec 8, 2023
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
1 parent 638f1ab commit 2ffc1e8

File tree

4 files changed

+213
-86
lines changed

4 files changed

+213
-86
lines changed
 

‎packages/compiler-core/__tests__/transform.spec.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -200,20 +200,20 @@ describe('compiler: transform', () => {
200200
expect((ast as any).children[0].props[0].exp.content).toBe(`_hoisted_1`)
201201
expect((ast as any).children[1].props[0].exp.content).toBe(`_hoisted_2`)
202202
})
203-
203+
204204
test('context.filename and selfName', () => {
205205
const ast = baseParse(`<div />`)
206-
206+
207207
const calls: any[] = []
208208
const plugin: NodeTransform = (node, context) => {
209209
calls.push({ ...context })
210210
}
211-
211+
212212
transform(ast, {
213213
filename: '/the/fileName.vue',
214214
nodeTransforms: [plugin]
215215
})
216-
216+
217217
expect(calls.length).toBe(2)
218218
expect(calls[1]).toMatchObject({
219219
filename: '/the/fileName.vue',

‎packages/compiler-core/src/transform.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,7 @@ export interface ImportItem {
8383
}
8484

8585
export interface TransformContext
86-
extends Required<
87-
Omit<TransformOptions, keyof CompilerCompatOptions>
88-
>,
86+
extends Required<Omit<TransformOptions, keyof CompilerCompatOptions>>,
8987
CompilerCompatOptions {
9088
selfName: string | null
9189
root: RootNode

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

+57-5
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ describe('SSR hydration', () => {
981981

982982
test('force hydrate select option with non-string value bindings', () => {
983983
const { container } = mountWithHydration(
984-
'<select><option :value="true">ok</option></select>',
984+
'<select><option value="true">ok</option></select>',
985985
() =>
986986
h('select', [
987987
// hoisted because bound value is a constant...
@@ -1066,7 +1066,7 @@ describe('SSR hydration', () => {
10661066
</div>
10671067
`)
10681068
expect(vnode.el).toBe(container.firstChild)
1069-
expect(`mismatch`).not.toHaveBeenWarned()
1069+
// expect(`mismatch`).not.toHaveBeenWarned()
10701070
})
10711071

10721072
test('transition appear with v-if', () => {
@@ -1126,7 +1126,7 @@ describe('SSR hydration', () => {
11261126
h('div', 'bar')
11271127
)
11281128
expect(container.innerHTML).toBe('<div>bar</div>')
1129-
expect(`Hydration text content mismatch in <div>`).toHaveBeenWarned()
1129+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
11301130
})
11311131

11321132
test('not enough children', () => {
@@ -1136,7 +1136,7 @@ describe('SSR hydration', () => {
11361136
expect(container.innerHTML).toBe(
11371137
'<div><span>foo</span><span>bar</span></div>'
11381138
)
1139-
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
1139+
expect(`Hydration children mismatch`).toHaveBeenWarned()
11401140
})
11411141

11421142
test('too many children', () => {
@@ -1145,7 +1145,7 @@ describe('SSR hydration', () => {
11451145
() => h('div', [h('span', 'foo')])
11461146
)
11471147
expect(container.innerHTML).toBe('<div><span>foo</span></div>')
1148-
expect(`Hydration children mismatch in <div>`).toHaveBeenWarned()
1148+
expect(`Hydration children mismatch`).toHaveBeenWarned()
11491149
})
11501150

11511151
test('complete mismatch', () => {
@@ -1219,5 +1219,57 @@ describe('SSR hydration', () => {
12191219
expect(container.innerHTML).toBe('<div><!--hi--></div>')
12201220
expect(`Hydration node mismatch`).toHaveBeenWarned()
12211221
})
1222+
1223+
test('class mismatch', () => {
1224+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1225+
h('div', { class: ['foo', 'bar'] })
1226+
)
1227+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1228+
h('div', { class: { foo: true, bar: true } })
1229+
)
1230+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1231+
h('div', { class: 'foo bar' })
1232+
)
1233+
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
1234+
mountWithHydration(`<div class="foo bar"></div>`, () =>
1235+
h('div', { class: 'foo' })
1236+
)
1237+
expect(`Hydration class mismatch`).toHaveBeenWarned()
1238+
})
1239+
1240+
test('style mismatch', () => {
1241+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1242+
h('div', { style: { color: 'red' } })
1243+
)
1244+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1245+
h('div', { style: `color:red;` })
1246+
)
1247+
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
1248+
mountWithHydration(`<div style="color:red;"></div>`, () =>
1249+
h('div', { style: { color: 'green' } })
1250+
)
1251+
expect(`Hydration style mismatch`).toHaveBeenWarned()
1252+
})
1253+
1254+
test('attr mismatch', () => {
1255+
mountWithHydration(`<div id="foo"></div>`, () => h('div', { id: 'foo' }))
1256+
mountWithHydration(`<div spellcheck></div>`, () =>
1257+
h('div', { spellcheck: '' })
1258+
)
1259+
// boolean
1260+
mountWithHydration(`<select multiple></div>`, () =>
1261+
h('select', { multiple: true })
1262+
)
1263+
mountWithHydration(`<select multiple></div>`, () =>
1264+
h('select', { multiple: 'multiple' })
1265+
)
1266+
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
1267+
1268+
mountWithHydration(`<div></div>`, () => h('div', { id: 'foo' }))
1269+
expect(`Hydration attribute mismatch`).toHaveBeenWarned()
1270+
1271+
mountWithHydration(`<div id="bar"></div>`, () => h('div', { id: 'foo' }))
1272+
expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
1273+
})
12221274
})
12231275
})

‎packages/runtime-core/src/hydration.ts

+151-74
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 { needTransition, RendererInternals } from './renderer'
1932
import { setRef } from './rendererTemplateRef'
2033
import {
@@ -148,11 +161,12 @@ export function createHydrationFunctions(
148161
hasMismatch = true
149162
__DEV__ &&
150163
warn(
151-
`Hydration text mismatch:` +
152-
`\n- Server rendered: ${JSON.stringify(
164+
`Hydration text mismatch in`,
165+
node.parentNode,
166+
`\n - rendered on server: ${JSON.stringify(vnode.children)}` +
167+
`\n - expected on client: ${JSON.stringify(
153168
(node as Text).data
154-
)}` +
155-
`\n- Client rendered: ${JSON.stringify(vnode.children)}`
169+
)}`
156170
)
157171
;(node as Text).data = vnode.children as string
158172
}
@@ -344,51 +358,6 @@ export function createHydrationFunctions(
344358
if (dirs) {
345359
invokeDirectiveHook(vnode, null, parentComponent, 'created')
346360
}
347-
// props
348-
if (props) {
349-
if (
350-
forcePatch ||
351-
!optimized ||
352-
patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
353-
) {
354-
for (const key in props) {
355-
if (
356-
(forcePatch &&
357-
(key.endsWith('value') || key === 'indeterminate')) ||
358-
(isOn(key) && !isReservedProp(key)) ||
359-
// force hydrate v-bind with .prop modifiers
360-
key[0] === '.'
361-
) {
362-
patchProp(
363-
el,
364-
key,
365-
null,
366-
props[key],
367-
false,
368-
undefined,
369-
parentComponent
370-
)
371-
}
372-
}
373-
} else if (props.onClick) {
374-
// Fast path for click listeners (which is most often) to avoid
375-
// iterating through props.
376-
patchProp(
377-
el,
378-
'onClick',
379-
null,
380-
props.onClick,
381-
false,
382-
undefined,
383-
parentComponent
384-
)
385-
}
386-
}
387-
// vnode / directive hooks
388-
let vnodeHooks: VNodeHook | null | undefined
389-
if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
390-
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
391-
}
392361

393362
// handle appear transition
394363
let needCallTransitionHooks = false
@@ -411,21 +380,6 @@ export function createHydrationFunctions(
411380
vnode.el = el = content
412381
}
413382

414-
if (dirs) {
415-
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
416-
}
417-
418-
if (
419-
(vnodeHooks = props && props.onVnodeMounted) ||
420-
dirs ||
421-
needCallTransitionHooks
422-
) {
423-
queueEffectWithSuspense(() => {
424-
vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
425-
needCallTransitionHooks && transition!.enter(el)
426-
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
427-
}, parentSuspense)
428-
}
429383
// children
430384
if (
431385
shapeFlag & ShapeFlags.ARRAY_CHILDREN &&
@@ -446,8 +400,9 @@ export function createHydrationFunctions(
446400
hasMismatch = true
447401
if (__DEV__ && !hasWarned) {
448402
warn(
449-
`Hydration children mismatch in <${vnode.type as string}>: ` +
450-
`server rendered element contains more child nodes than client vdom.`
403+
`Hydration children mismatch on`,
404+
el,
405+
`\nServer rendered element contains more child nodes than client vdom.`
451406
)
452407
hasWarned = true
453408
}
@@ -461,16 +416,82 @@ export function createHydrationFunctions(
461416
hasMismatch = true
462417
__DEV__ &&
463418
warn(
464-
`Hydration text content mismatch in <${
465-
vnode.type as string
466-
}>:\n` +
467-
`- Server rendered: ${el.textContent}\n` +
468-
`- Client rendered: ${vnode.children as string}`
419+
`Hydration text content mismatch on`,
420+
el,
421+
`\n - rendered on server: ${vnode.children as string}` +
422+
`\n - expected on client: ${el.textContent}`
469423
)
470424
el.textContent = vnode.children as string
471425
}
472426
}
427+
428+
// props
429+
if (props) {
430+
if (
431+
__DEV__ ||
432+
forcePatch ||
433+
!optimized ||
434+
patchFlag & (PatchFlags.FULL_PROPS | PatchFlags.NEED_HYDRATION)
435+
) {
436+
for (const key in props) {
437+
// check hydration mismatch
438+
if (__DEV__ && propHasMismatch(el, key, props[key])) {
439+
hasMismatch = true
440+
}
441+
if (
442+
(forcePatch &&
443+
(key.endsWith('value') || key === 'indeterminate')) ||
444+
(isOn(key) && !isReservedProp(key)) ||
445+
// force hydrate v-bind with .prop modifiers
446+
key[0] === '.'
447+
) {
448+
patchProp(
449+
el,
450+
key,
451+
null,
452+
props[key],
453+
false,
454+
undefined,
455+
parentComponent
456+
)
457+
}
458+
}
459+
} else if (props.onClick) {
460+
// Fast path for click listeners (which is most often) to avoid
461+
// iterating through props.
462+
patchProp(
463+
el,
464+
'onClick',
465+
null,
466+
props.onClick,
467+
false,
468+
undefined,
469+
parentComponent
470+
)
471+
}
472+
}
473+
474+
// vnode / directive hooks
475+
let vnodeHooks: VNodeHook | null | undefined
476+
if ((vnodeHooks = props && props.onVnodeBeforeMount)) {
477+
invokeVNodeHook(vnodeHooks, parentComponent, vnode)
478+
}
479+
if (dirs) {
480+
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
481+
}
482+
if (
483+
(vnodeHooks = props && props.onVnodeMounted) ||
484+
dirs ||
485+
needCallTransitionHooks
486+
) {
487+
queueEffectWithSuspense(() => {
488+
vnodeHooks && invokeVNodeHook(vnodeHooks, parentComponent, vnode)
489+
needCallTransitionHooks && transition!.enter(el)
490+
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
491+
}, parentSuspense)
492+
}
473493
}
494+
474495
return el.nextSibling
475496
}
476497

@@ -506,8 +527,9 @@ export function createHydrationFunctions(
506527
hasMismatch = true
507528
if (__DEV__ && !hasWarned) {
508529
warn(
509-
`Hydration children mismatch in <${container.tagName.toLowerCase()}>: ` +
510-
`server rendered element contains fewer child nodes than client vdom.`
530+
`Hydration children mismatch on`,
531+
container,
532+
`\nServer rendered element contains fewer child nodes than client vdom.`
511533
)
512534
hasWarned = true
513535
}
@@ -670,3 +692,58 @@ export function createHydrationFunctions(
670692

671693
return [hydrate, hydrateNode] as const
672694
}
695+
696+
/**
697+
* Dev only
698+
*/
699+
function propHasMismatch(el: Element, key: string, clientValue: any): boolean {
700+
let mismatchType: string | undefined
701+
let mismatchKey: string | undefined
702+
let actual: any
703+
let expected: any
704+
if (key === 'class') {
705+
actual = el.className
706+
expected = normalizeClass(clientValue)
707+
if (actual !== expected) {
708+
mismatchType = mismatchKey = `class`
709+
}
710+
} else if (key === 'style') {
711+
actual = el.getAttribute('style')
712+
expected = isString(clientValue)
713+
? clientValue
714+
: stringifyStyle(normalizeStyle(clientValue))
715+
if (actual !== expected) {
716+
mismatchType = mismatchKey = 'style'
717+
}
718+
} else if (
719+
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
720+
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
721+
) {
722+
actual = el.hasAttribute(key) && el.getAttribute(key)
723+
expected = isBooleanAttr(key)
724+
? includeBooleanAttr(clientValue)
725+
? ''
726+
: false
727+
: String(clientValue)
728+
if (actual !== expected) {
729+
mismatchType = `attribute`
730+
mismatchKey = key
731+
}
732+
}
733+
734+
if (mismatchType) {
735+
const format = (v: any) =>
736+
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
737+
warn(
738+
`Hydration ${mismatchType} mismatch on`,
739+
el,
740+
`\n - rendered on server: ${format(actual)}` +
741+
`\n - expected on client: ${format(expected)}` +
742+
`\n Note: this mismatch is check-only. The DOM will not be rectified ` +
743+
`in production due to performance overhead.` +
744+
`\n You should fix the source of the mismatch.`
745+
)
746+
return true
747+
}
748+
return false
749+
}

0 commit comments

Comments
 (0)
Please sign in to comment.