feat(ssr): improve fragment mismatch handling

This commit is contained in:
Evan You 2020-03-13 18:02:53 -04:00
parent eb1d538ea2
commit 60ed4e7e08
2 changed files with 93 additions and 34 deletions

View File

@ -110,8 +110,9 @@ describe('SSR hydration', () => {
) )
expect(vnode.el).toBe(container.firstChild) expect(vnode.el).toBe(container.firstChild)
// should remove anchors in dev mode expect(vnode.el.innerHTML).toBe(
expect(vnode.el.innerHTML).toBe(`<span>foo</span><span class="foo"></span>`) `<!--[--><span>foo</span><!--[--><span class="foo"></span><!--]--><!--]-->`
)
// start fragment 1 // start fragment 1
const fragment1 = (vnode.children as VNode[])[0] const fragment1 = (vnode.children as VNode[])[0]
@ -143,7 +144,9 @@ describe('SSR hydration', () => {
msg.value = 'bar' msg.value = 'bar'
await nextTick() await nextTick()
expect(vnode.el.innerHTML).toBe(`<span>bar</span><span class="bar"></span>`) expect(vnode.el.innerHTML).toBe(
`<!--[--><span>bar</span><!--[--><span class="bar"></span><!--]--><!--]-->`
)
}) })
test('Portal', async () => { test('Portal', async () => {
@ -363,7 +366,6 @@ describe('SSR hydration', () => {
// should flush buffered effects // should flush buffered effects
expect(mountedCalls).toMatchObject([1, 2]) expect(mountedCalls).toMatchObject([1, 2])
// should have removed fragment markers
expect(container.innerHTML).toMatch(`<span>1</span><span>2</span>`) expect(container.innerHTML).toMatch(`<span>1</span><span>2</span>`)
const span1 = container.querySelector('span')! const span1 = container.querySelector('span')!
@ -419,5 +421,40 @@ describe('SSR hydration', () => {
expect(container.innerHTML).toBe('<div><div>foo</div><p>bar</p></div>') expect(container.innerHTML).toBe('<div><div>foo</div><p>bar</p></div>')
expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2) expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2)
}) })
test('fragment mismatch removal', () => {
const { container } = mountWithHydration(
`<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
() => h('div', [h('span', 'replaced')])
)
expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
expect(`Hydration node mismatch`).toHaveBeenWarned()
})
test('fragment not enough children', () => {
const { container } = mountWithHydration(
`<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')])
)
expect(container.innerHTML).toBe(
'<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>'
)
expect(`Hydration node mismatch`).toHaveBeenWarned()
})
test('fragment too many children', () => {
const { container } = mountWithHydration(
`<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo')], h('div', 'baz')])
)
expect(container.innerHTML).toBe(
'<div><!--[--><div>foo</div><!--]--><div>baz</div></div>'
)
// fragment ends early and attempts to hydrate the extra <div>bar</div>
// as 2nd fragment child.
expect(`Hydration text content mismatch`).toHaveBeenWarned()
// exccesive children removal
expect(`Hydration children mismatch`).toHaveBeenWarned()
})
}) })
}) })

View File

@ -47,7 +47,7 @@ export function createHydrationFunctions(
const { const {
mt: mountComponent, mt: mountComponent,
p: patch, p: patch,
o: { patchProp, nextSibling, parentNode } o: { patchProp, nextSibling, parentNode, remove, insert, createComment }
} = rendererInternals } = rendererInternals
const hydrate: RootHydrateFunction = (vnode, container) => { const hydrate: RootHydrateFunction = (vnode, container) => {
@ -76,11 +76,14 @@ export function createHydrationFunctions(
optimized = false optimized = false
): Node | null => { ): Node | null => {
const isFragmentStart = isComment(node) && node.data === '[' const isFragmentStart = isComment(node) && node.data === '['
if (__DEV__ && isFragmentStart) { const onMismatch = () =>
// in dev mode, replace comment anchors with invisible text nodes handleMismtach(
// for easier debugging node,
node = replaceAnchor(node, parentNode(node)!) vnode,
} parentComponent,
parentSuspense,
isFragmentStart
)
const { type, shapeFlag } = vnode const { type, shapeFlag } = vnode
const domType = node.nodeType const domType = node.nodeType
@ -89,7 +92,7 @@ export function createHydrationFunctions(
switch (type) { switch (type) {
case Text: case Text:
if (domType !== DOMNodeTypes.TEXT) { if (domType !== DOMNodeTypes.TEXT) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
if ((node as Text).data !== vnode.children) { if ((node as Text).data !== vnode.children) {
hasMismatch = true hasMismatch = true
@ -103,18 +106,18 @@ export function createHydrationFunctions(
} }
return nextSibling(node) return nextSibling(node)
case Comment: case Comment:
if (domType !== DOMNodeTypes.COMMENT) { if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
return nextSibling(node) return nextSibling(node)
case Static: case Static:
if (domType !== DOMNodeTypes.ELEMENT) { if (domType !== DOMNodeTypes.ELEMENT) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
return nextSibling(node) return nextSibling(node)
case Fragment: case Fragment:
if (domType !== (__DEV__ ? DOMNodeTypes.TEXT : DOMNodeTypes.COMMENT)) { if (!isFragmentStart) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
return hydrateFragment( return hydrateFragment(
node as Comment, node as Comment,
@ -129,7 +132,7 @@ export function createHydrationFunctions(
domType !== DOMNodeTypes.ELEMENT || domType !== DOMNodeTypes.ELEMENT ||
vnode.type !== (node as Element).tagName.toLowerCase() vnode.type !== (node as Element).tagName.toLowerCase()
) { ) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
return hydrateElement( return hydrateElement(
node as Element, node as Element,
@ -159,7 +162,7 @@ export function createHydrationFunctions(
: nextSibling(node) : nextSibling(node)
} else if (shapeFlag & ShapeFlags.PORTAL) { } else if (shapeFlag & ShapeFlags.PORTAL) {
if (domType !== DOMNodeTypes.COMMENT) { if (domType !== DOMNodeTypes.COMMENT) {
return handleMismtach(node, vnode, parentComponent, parentSuspense) return onMismatch()
} }
hydratePortal(vnode, parentComponent, parentSuspense, optimized) hydratePortal(vnode, parentComponent, parentSuspense, optimized)
return nextSibling(node) return nextSibling(node)
@ -247,7 +250,7 @@ export function createHydrationFunctions(
// The SSRed DOM contains more nodes than it should. Remove them. // The SSRed DOM contains more nodes than it should. Remove them.
const cur = next const cur = next
next = next.nextSibling next = next.nextSibling
el.removeChild(cur) remove(cur)
} }
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (el.textContent !== vnode.children) { if (el.textContent !== vnode.children) {
@ -321,18 +324,24 @@ export function createHydrationFunctions(
optimized: boolean optimized: boolean
) => { ) => {
const container = parentNode(node)! const container = parentNode(node)!
let next = hydrateChildren( const next = hydrateChildren(
nextSibling(node)!, nextSibling(node)!,
vnode, vnode,
container, container,
parentComponent, parentComponent,
parentSuspense, parentSuspense,
optimized optimized
)! )
if (__DEV__) { if (next && isComment(next) && next.data === ']') {
next = replaceAnchor(next, container) return nextSibling((vnode.anchor = next))
} else {
// fragment didn't hydrate successfully, since we didn't get a end anchor
// back. This should have led to node/children mismatch warnings.
hasMismatch = true
// since the anchor is missing, we need to create one and insert it
insert((vnode.anchor = createComment(`]`)), container, next)
return next
} }
return nextSibling((vnode.anchor = next))
} }
const hydratePortal = ( const hydratePortal = (
@ -366,7 +375,8 @@ export function createHydrationFunctions(
node: Node, node: Node,
vnode: VNode, vnode: VNode,
parentComponent: ComponentInternalInstance | null, parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null parentSuspense: SuspenseBoundary | null,
isFragment: boolean
) => { ) => {
hasMismatch = true hasMismatch = true
__DEV__ && __DEV__ &&
@ -375,12 +385,31 @@ export function createHydrationFunctions(
vnode.type, vnode.type,
`\n- Server rendered DOM:`, `\n- Server rendered DOM:`,
node, node,
node.nodeType === DOMNodeTypes.TEXT ? `(text)` : `` node.nodeType === DOMNodeTypes.TEXT
? `(text)`
: isComment(node) && node.data === '['
? `(start of fragment)`
: ``
) )
vnode.el = null vnode.el = null
if (isFragment) {
// remove excessive fragment nodes
const end = locateClosingAsyncAnchor(node)
while (true) {
const next = nextSibling(node)
if (next && next !== end) {
remove(next)
} else {
break
}
}
}
const next = nextSibling(node) const next = nextSibling(node)
const container = parentNode(node)! const container = parentNode(node)!
container.removeChild(node) remove(node)
patch( patch(
null, null,
vnode, vnode,
@ -411,12 +440,5 @@ export function createHydrationFunctions(
return node return node
} }
const replaceAnchor = (node: Node, parent: Element): Node => {
const text = document.createTextNode('')
parent.insertBefore(text, node)
parent.removeChild(node)
return text
}
return [hydrate, hydrateNode] as const return [hydrate, hydrateNode] as const
} }