Skip to content

Commit 6c8471b

Browse files
committed
wip: hydration mismatch handling
1 parent c252e91 commit 6c8471b

File tree

6 files changed

+123
-35
lines changed

6 files changed

+123
-35
lines changed

packages/runtime-core/src/hydration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -991,7 +991,7 @@ function resolveCssVars(
991991

992992
const allowMismatchAttr = 'data-allow-mismatch'
993993

994-
enum MismatchTypes {
994+
export enum MismatchTypes {
995995
TEXT = 0,
996996
CHILDREN = 1,
997997
CLASS = 2,
@@ -1007,7 +1007,7 @@ const MismatchTypeString: Record<MismatchTypes, string> = {
10071007
[MismatchTypes.ATTRIBUTE]: 'attribute',
10081008
} as const
10091009

1010-
function isMismatchAllowed(
1010+
export function isMismatchAllowed(
10111011
el: Element | null,
10121012
allowedType: MismatchTypes,
10131013
): boolean {

packages/runtime-core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,7 @@ export { initFeatureFlags } from './featureFlags'
562562
* @internal
563563
*/
564564
export { createInternalObject } from './internalObject'
565+
/**
566+
* @internal
567+
*/
568+
export { MismatchTypes, isMismatchAllowed } from './hydration'

packages/runtime-vapor/src/block.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,6 @@ export class DynamicFragment extends VaporFragment {
9999
this.anchor = locateFragmentAnchor(currentHydrationNode!, label)!
100100
if (this.anchor) {
101101
advanceHydrationNode(this.anchor)
102-
} else if (__DEV__) {
103-
throw new Error(`${label} fragment anchor node was not found.`)
104102
}
105103
}
106104
}

packages/runtime-vapor/src/dom/hydration.ts

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { warn } from '@vue/runtime-dom'
1+
import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom'
22
import {
33
insertionAnchor,
44
insertionParent,
@@ -8,11 +8,15 @@ import {
88
import {
99
_child,
1010
_next,
11+
child,
12+
createElement,
1113
createTextNode,
1214
disableHydrationNodeLookup,
1315
enableHydrationNodeLookup,
16+
parentNode,
1417
} from './node'
1518
import { BLOCK_ANCHOR_END_LABEL, BLOCK_ANCHOR_START_LABEL } from '@vue/shared'
19+
import { insert, remove } from '../block'
1620

1721
const isHydratingStack = [] as boolean[]
1822
export let isHydrating = false
@@ -83,7 +87,7 @@ export function advanceHydrationNode(
8387
): void {
8488
// if no next sibling, find the next node in the parent chain
8589
const ret =
86-
node.nextSibling ||
90+
_next(node) ||
8791
// pns is short for "parent next sibling"
8892
node.$pns ||
8993
(node.$pns = locateNextSiblingOfParent(node))
@@ -97,35 +101,45 @@ export function advanceHydrationNode(
97101
function adoptTemplateImpl(node: Node, template: string): Node | null {
98102
if (!(template[0] === '<' && template[1] === '!')) {
99103
while (node.nodeType === 8) {
100-
node = node.nextSibling!
104+
node = _next(node)
101105

102106
// empty text node in slot
103107
if (
104108
template.trim() === '' &&
105109
isComment(node, ']') &&
106110
isComment(node.previousSibling!, '[')
107111
) {
108-
node = node.parentNode!.insertBefore(createTextNode(' '), node)
112+
node = parentNode(node)!.insertBefore(createTextNode(' '), node)
109113
break
110114
}
111115
}
112116
}
113117

114-
if (__DEV__) {
115-
const type = node.nodeType
116-
if (
117-
(type === 8 && !template.startsWith('<!')) ||
118-
(type === 1 &&
119-
!template.startsWith(`<` + (node as Element).tagName.toLowerCase())) ||
120-
(type === 3 &&
121-
template.trim() &&
122-
!template.startsWith((node as Text).data))
123-
) {
124-
// TODO recover and provide more info
125-
warn(`adopted: `, node)
126-
warn(`template: ${template}`)
127-
warn('hydration mismatch!')
128-
}
118+
const type = node.nodeType
119+
if (
120+
// comment node
121+
(type === 8 && !template.startsWith('<!')) ||
122+
// element node
123+
(type === 1 &&
124+
!template.startsWith(`<` + (node as Element).tagName.toLowerCase()))
125+
) {
126+
node = handleMismatch(node, template)
127+
}
128+
// text node
129+
else if (
130+
type === 3 &&
131+
template.trim() &&
132+
!template.startsWith((node as Text).data)
133+
) {
134+
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
135+
warn(
136+
`Hydration text mismatch in`,
137+
parentNode(node),
138+
`\n - rendered on server: ${JSON.stringify((node as Text).data)}` +
139+
`\n - expected on client: ${JSON.stringify(template)}`,
140+
)
141+
logMismatchError()
142+
;(node as Text).data = template
129143
}
130144

131145
advanceHydrationNode(node)
@@ -141,14 +155,16 @@ function locateHydrationNodeImpl(): void {
141155
)!
142156
} else {
143157
node = currentHydrationNode
144-
if (insertionParent && (!node || node.parentNode !== insertionParent)) {
158+
if (insertionParent && (!node || parentNode(node) !== insertionParent)) {
145159
node = _child(insertionParent)
146160
}
147161
}
148162

149163
if (__DEV__ && !node) {
150-
// TODO more info
151-
warn('Hydration mismatch in ', insertionParent)
164+
throw new Error(
165+
`No current hydration node was found.\n` +
166+
`this is likely a Vue internal bug.`,
167+
)
152168
}
153169

154170
resetInsertionState()
@@ -166,7 +182,7 @@ export function locateEndAnchor(
166182
}
167183

168184
const stack: Anchor[] = [node]
169-
while ((node = node.nextSibling as Anchor) && stack.length > 0) {
185+
while ((node = _next(node) as Anchor) && stack.length > 0) {
170186
if (node.nodeType === 8) {
171187
if (node.data === open) {
172188
stack.push(node)
@@ -187,20 +203,29 @@ export function locateFragmentAnchor(
187203
): Comment | null {
188204
while (node && node.nodeType === 8) {
189205
if ((node as Comment).data === label) return node as Comment
190-
node = node.nextSibling!
206+
node = _next(node)
207+
}
208+
209+
if (__DEV__) {
210+
throw new Error(
211+
`Could not locate fragment anchor node with label: ${label}\n` +
212+
`this is likely a Vue internal bug.`,
213+
)
191214
}
215+
192216
return null
193217
}
194218

195219
function locateNextBlockNode(node: Node): Node | null {
196220
while (node) {
197-
if (isComment(node, BLOCK_ANCHOR_START_LABEL)) return node.nextSibling
198-
node = node.nextSibling!
221+
if (isComment(node, BLOCK_ANCHOR_START_LABEL)) return _next(node)
222+
node = _next(node)
199223
}
200224

201225
if (__DEV__) {
202226
throw new Error(
203-
`Could not locate hydration node with anchor label: ${BLOCK_ANCHOR_START_LABEL}`,
227+
`Could not locate hydration node with anchor label: ${BLOCK_ANCHOR_START_LABEL}\n` +
228+
`this is likely a Vue internal bug.`,
204229
)
205230
}
206231
return null
@@ -221,3 +246,63 @@ export function advanceToNonBlockNode(node: Node): Node {
221246
}
222247
return node
223248
}
249+
250+
function handleMismatch(node: Node, template: string): Node {
251+
if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
252+
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
253+
warn(
254+
`Hydration node mismatch:\n- rendered on server:`,
255+
node,
256+
node.nodeType === 3
257+
? `(text)`
258+
: isComment(node, '[[')
259+
? `(start of block node)`
260+
: ``,
261+
`\n- expected on client:`,
262+
template,
263+
)
264+
logMismatchError()
265+
}
266+
267+
// block node start
268+
if (isComment(node, BLOCK_ANCHOR_START_LABEL)) {
269+
const end = locateEndAnchor(
270+
node as Anchor,
271+
BLOCK_ANCHOR_START_LABEL,
272+
BLOCK_ANCHOR_END_LABEL,
273+
)
274+
while (true) {
275+
const next = _next(node)
276+
if (next && next !== end) {
277+
remove(next, parentNode(node)!)
278+
} else {
279+
break
280+
}
281+
}
282+
}
283+
284+
const next = _next(node)
285+
const container = parentNode(node)!
286+
remove(node, container)
287+
288+
let newNode: Node | null
289+
if (template[0] !== '<') {
290+
newNode = createTextNode(template)
291+
} else {
292+
const t = createElement('template') as HTMLTemplateElement
293+
t.innerHTML = template
294+
newNode = child(t.content).cloneNode(true)
295+
}
296+
insert(newNode, container, next)
297+
return newNode
298+
}
299+
300+
let hasLoggedMismatchError = false
301+
const logMismatchError = () => {
302+
if (__TEST__ || hasLoggedMismatchError) {
303+
return
304+
}
305+
// this error should show up in production
306+
console.error('Hydration completed but contains mismatches.')
307+
hasLoggedMismatchError = true
308+
}

packages/runtime-vapor/src/dom/node.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ export function querySelector(selectors: string): Element | null {
2121
return document.querySelector(selectors)
2222
}
2323

24+
/*! #__NO_SIDE_EFFECTS__ */
25+
export function parentNode(node: Node): ParentNode | null {
26+
return node.parentNode
27+
}
28+
2429
/*! #__NO_SIDE_EFFECTS__ */
2530
const _txt: typeof _child = _child
2631

packages/runtime-vapor/src/dom/template.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ export function template(html: string, root?: boolean) {
88
let node: Node
99
return (): Node & { $root?: true } => {
1010
if (isHydrating) {
11-
if (__DEV__ && !currentHydrationNode) {
12-
// TODO this should not happen
13-
throw new Error('No current hydration node')
14-
}
1511
// do not cache the adopted node in node because it contains child nodes
1612
// this avoids duplicate rendering of children
1713
const adopted = adoptTemplate(currentHydrationNode!, html)!

0 commit comments

Comments
 (0)