Skip to content

Commit 60ed4e7

Browse files
committed
feat(ssr): improve fragment mismatch handling
1 parent eb1d538 commit 60ed4e7

File tree

2 files changed

+93
-34
lines changed

2 files changed

+93
-34
lines changed

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

+41-4
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ describe('SSR hydration', () => {
110110
)
111111
expect(vnode.el).toBe(container.firstChild)
112112

113-
// should remove anchors in dev mode
114-
expect(vnode.el.innerHTML).toBe(`<span>foo</span><span class="foo"></span>`)
113+
expect(vnode.el.innerHTML).toBe(
114+
`<!--[--><span>foo</span><!--[--><span class="foo"></span><!--]--><!--]-->`
115+
)
115116

116117
// start fragment 1
117118
const fragment1 = (vnode.children as VNode[])[0]
@@ -143,7 +144,9 @@ describe('SSR hydration', () => {
143144

144145
msg.value = 'bar'
145146
await nextTick()
146-
expect(vnode.el.innerHTML).toBe(`<span>bar</span><span class="bar"></span>`)
147+
expect(vnode.el.innerHTML).toBe(
148+
`<!--[--><span>bar</span><!--[--><span class="bar"></span><!--]--><!--]-->`
149+
)
147150
})
148151

149152
test('Portal', async () => {
@@ -363,7 +366,6 @@ describe('SSR hydration', () => {
363366

364367
// should flush buffered effects
365368
expect(mountedCalls).toMatchObject([1, 2])
366-
// should have removed fragment markers
367369
expect(container.innerHTML).toMatch(`<span>1</span><span>2</span>`)
368370

369371
const span1 = container.querySelector('span')!
@@ -419,5 +421,40 @@ describe('SSR hydration', () => {
419421
expect(container.innerHTML).toBe('<div><div>foo</div><p>bar</p></div>')
420422
expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2)
421423
})
424+
425+
test('fragment mismatch removal', () => {
426+
const { container } = mountWithHydration(
427+
`<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
428+
() => h('div', [h('span', 'replaced')])
429+
)
430+
expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
431+
expect(`Hydration node mismatch`).toHaveBeenWarned()
432+
})
433+
434+
test('fragment not enough children', () => {
435+
const { container } = mountWithHydration(
436+
`<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
437+
() => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')])
438+
)
439+
expect(container.innerHTML).toBe(
440+
'<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>'
441+
)
442+
expect(`Hydration node mismatch`).toHaveBeenWarned()
443+
})
444+
445+
test('fragment too many children', () => {
446+
const { container } = mountWithHydration(
447+
`<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
448+
() => h('div', [[h('div', 'foo')], h('div', 'baz')])
449+
)
450+
expect(container.innerHTML).toBe(
451+
'<div><!--[--><div>foo</div><!--]--><div>baz</div></div>'
452+
)
453+
// fragment ends early and attempts to hydrate the extra <div>bar</div>
454+
// as 2nd fragment child.
455+
expect(`Hydration text content mismatch`).toHaveBeenWarned()
456+
// exccesive children removal
457+
expect(`Hydration children mismatch`).toHaveBeenWarned()
458+
})
422459
})
423460
})

packages/runtime-core/src/hydration.ts

+52-30
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function createHydrationFunctions(
4747
const {
4848
mt: mountComponent,
4949
p: patch,
50-
o: { patchProp, nextSibling, parentNode }
50+
o: { patchProp, nextSibling, parentNode, remove, insert, createComment }
5151
} = rendererInternals
5252

5353
const hydrate: RootHydrateFunction = (vnode, container) => {
@@ -76,11 +76,14 @@ export function createHydrationFunctions(
7676
optimized = false
7777
): Node | null => {
7878
const isFragmentStart = isComment(node) && node.data === '['
79-
if (__DEV__ && isFragmentStart) {
80-
// in dev mode, replace comment anchors with invisible text nodes
81-
// for easier debugging
82-
node = replaceAnchor(node, parentNode(node)!)
83-
}
79+
const onMismatch = () =>
80+
handleMismtach(
81+
node,
82+
vnode,
83+
parentComponent,
84+
parentSuspense,
85+
isFragmentStart
86+
)
8487

8588
const { type, shapeFlag } = vnode
8689
const domType = node.nodeType
@@ -89,7 +92,7 @@ export function createHydrationFunctions(
8992
switch (type) {
9093
case Text:
9194
if (domType !== DOMNodeTypes.TEXT) {
92-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
95+
return onMismatch()
9396
}
9497
if ((node as Text).data !== vnode.children) {
9598
hasMismatch = true
@@ -103,18 +106,18 @@ export function createHydrationFunctions(
103106
}
104107
return nextSibling(node)
105108
case Comment:
106-
if (domType !== DOMNodeTypes.COMMENT) {
107-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
109+
if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) {
110+
return onMismatch()
108111
}
109112
return nextSibling(node)
110113
case Static:
111114
if (domType !== DOMNodeTypes.ELEMENT) {
112-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
115+
return onMismatch()
113116
}
114117
return nextSibling(node)
115118
case Fragment:
116-
if (domType !== (__DEV__ ? DOMNodeTypes.TEXT : DOMNodeTypes.COMMENT)) {
117-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
119+
if (!isFragmentStart) {
120+
return onMismatch()
118121
}
119122
return hydrateFragment(
120123
node as Comment,
@@ -129,7 +132,7 @@ export function createHydrationFunctions(
129132
domType !== DOMNodeTypes.ELEMENT ||
130133
vnode.type !== (node as Element).tagName.toLowerCase()
131134
) {
132-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
135+
return onMismatch()
133136
}
134137
return hydrateElement(
135138
node as Element,
@@ -159,7 +162,7 @@ export function createHydrationFunctions(
159162
: nextSibling(node)
160163
} else if (shapeFlag & ShapeFlags.PORTAL) {
161164
if (domType !== DOMNodeTypes.COMMENT) {
162-
return handleMismtach(node, vnode, parentComponent, parentSuspense)
165+
return onMismatch()
163166
}
164167
hydratePortal(vnode, parentComponent, parentSuspense, optimized)
165168
return nextSibling(node)
@@ -247,7 +250,7 @@ export function createHydrationFunctions(
247250
// The SSRed DOM contains more nodes than it should. Remove them.
248251
const cur = next
249252
next = next.nextSibling
250-
el.removeChild(cur)
253+
remove(cur)
251254
}
252255
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
253256
if (el.textContent !== vnode.children) {
@@ -321,18 +324,24 @@ export function createHydrationFunctions(
321324
optimized: boolean
322325
) => {
323326
const container = parentNode(node)!
324-
let next = hydrateChildren(
327+
const next = hydrateChildren(
325328
nextSibling(node)!,
326329
vnode,
327330
container,
328331
parentComponent,
329332
parentSuspense,
330333
optimized
331-
)!
332-
if (__DEV__) {
333-
next = replaceAnchor(next, container)
334+
)
335+
if (next && isComment(next) && next.data === ']') {
336+
return nextSibling((vnode.anchor = next))
337+
} else {
338+
// fragment didn't hydrate successfully, since we didn't get a end anchor
339+
// back. This should have led to node/children mismatch warnings.
340+
hasMismatch = true
341+
// since the anchor is missing, we need to create one and insert it
342+
insert((vnode.anchor = createComment(`]`)), container, next)
343+
return next
334344
}
335-
return nextSibling((vnode.anchor = next))
336345
}
337346

338347
const hydratePortal = (
@@ -366,7 +375,8 @@ export function createHydrationFunctions(
366375
node: Node,
367376
vnode: VNode,
368377
parentComponent: ComponentInternalInstance | null,
369-
parentSuspense: SuspenseBoundary | null
378+
parentSuspense: SuspenseBoundary | null,
379+
isFragment: boolean
370380
) => {
371381
hasMismatch = true
372382
__DEV__ &&
@@ -375,12 +385,31 @@ export function createHydrationFunctions(
375385
vnode.type,
376386
`\n- Server rendered DOM:`,
377387
node,
378-
node.nodeType === DOMNodeTypes.TEXT ? `(text)` : ``
388+
node.nodeType === DOMNodeTypes.TEXT
389+
? `(text)`
390+
: isComment(node) && node.data === '['
391+
? `(start of fragment)`
392+
: ``
379393
)
380394
vnode.el = null
395+
396+
if (isFragment) {
397+
// remove excessive fragment nodes
398+
const end = locateClosingAsyncAnchor(node)
399+
while (true) {
400+
const next = nextSibling(node)
401+
if (next && next !== end) {
402+
remove(next)
403+
} else {
404+
break
405+
}
406+
}
407+
}
408+
381409
const next = nextSibling(node)
382410
const container = parentNode(node)!
383-
container.removeChild(node)
411+
remove(node)
412+
384413
patch(
385414
null,
386415
vnode,
@@ -411,12 +440,5 @@ export function createHydrationFunctions(
411440
return node
412441
}
413442

414-
const replaceAnchor = (node: Node, parent: Element): Node => {
415-
const text = document.createTextNode('')
416-
parent.insertBefore(text, node)
417-
parent.removeChild(node)
418-
return text
419-
}
420-
421443
return [hydrate, hydrateNode] as const
422444
}

0 commit comments

Comments
 (0)