From 60ed4e7e0821a2932660b87fbf8d5ca953e0e073 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 13 Mar 2020 18:02:53 -0400 Subject: [PATCH] feat(ssr): improve fragment mismatch handling --- .../runtime-core/__tests__/hydration.spec.ts | 45 +++++++++- packages/runtime-core/src/hydration.ts | 82 ++++++++++++------- 2 files changed, 93 insertions(+), 34 deletions(-) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index 5d40b4e5d01..67387bc52be 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -110,8 +110,9 @@ describe('SSR hydration', () => { ) expect(vnode.el).toBe(container.firstChild) - // should remove anchors in dev mode - expect(vnode.el.innerHTML).toBe(`foo`) + expect(vnode.el.innerHTML).toBe( + `foo` + ) // start fragment 1 const fragment1 = (vnode.children as VNode[])[0] @@ -143,7 +144,9 @@ describe('SSR hydration', () => { msg.value = 'bar' await nextTick() - expect(vnode.el.innerHTML).toBe(`bar`) + expect(vnode.el.innerHTML).toBe( + `bar` + ) }) test('Portal', async () => { @@ -363,7 +366,6 @@ describe('SSR hydration', () => { // should flush buffered effects expect(mountedCalls).toMatchObject([1, 2]) - // should have removed fragment markers expect(container.innerHTML).toMatch(`12`) const span1 = container.querySelector('span')! @@ -419,5 +421,40 @@ describe('SSR hydration', () => { expect(container.innerHTML).toBe('
foo

bar

') expect(`Hydration node mismatch`).toHaveBeenWarnedTimes(2) }) + + test('fragment mismatch removal', () => { + const { container } = mountWithHydration( + `
foo
bar
`, + () => h('div', [h('span', 'replaced')]) + ) + expect(container.innerHTML).toBe('
replaced
') + expect(`Hydration node mismatch`).toHaveBeenWarned() + }) + + test('fragment not enough children', () => { + const { container } = mountWithHydration( + `
foo
baz
`, + () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]) + ) + expect(container.innerHTML).toBe( + '
foo
bar
baz
' + ) + expect(`Hydration node mismatch`).toHaveBeenWarned() + }) + + test('fragment too many children', () => { + const { container } = mountWithHydration( + `
foo
bar
baz
`, + () => h('div', [[h('div', 'foo')], h('div', 'baz')]) + ) + expect(container.innerHTML).toBe( + '
foo
baz
' + ) + // fragment ends early and attempts to hydrate the extra
bar
+ // as 2nd fragment child. + expect(`Hydration text content mismatch`).toHaveBeenWarned() + // exccesive children removal + expect(`Hydration children mismatch`).toHaveBeenWarned() + }) }) }) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index c0ee245edb1..945fb8a3075 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -47,7 +47,7 @@ export function createHydrationFunctions( const { mt: mountComponent, p: patch, - o: { patchProp, nextSibling, parentNode } + o: { patchProp, nextSibling, parentNode, remove, insert, createComment } } = rendererInternals const hydrate: RootHydrateFunction = (vnode, container) => { @@ -76,11 +76,14 @@ export function createHydrationFunctions( optimized = false ): Node | null => { const isFragmentStart = isComment(node) && node.data === '[' - if (__DEV__ && isFragmentStart) { - // in dev mode, replace comment anchors with invisible text nodes - // for easier debugging - node = replaceAnchor(node, parentNode(node)!) - } + const onMismatch = () => + handleMismtach( + node, + vnode, + parentComponent, + parentSuspense, + isFragmentStart + ) const { type, shapeFlag } = vnode const domType = node.nodeType @@ -89,7 +92,7 @@ export function createHydrationFunctions( switch (type) { case Text: if (domType !== DOMNodeTypes.TEXT) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + return onMismatch() } if ((node as Text).data !== vnode.children) { hasMismatch = true @@ -103,18 +106,18 @@ export function createHydrationFunctions( } return nextSibling(node) case Comment: - if (domType !== DOMNodeTypes.COMMENT) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + if (domType !== DOMNodeTypes.COMMENT || isFragmentStart) { + return onMismatch() } return nextSibling(node) case Static: if (domType !== DOMNodeTypes.ELEMENT) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + return onMismatch() } return nextSibling(node) case Fragment: - if (domType !== (__DEV__ ? DOMNodeTypes.TEXT : DOMNodeTypes.COMMENT)) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + if (!isFragmentStart) { + return onMismatch() } return hydrateFragment( node as Comment, @@ -129,7 +132,7 @@ export function createHydrationFunctions( domType !== DOMNodeTypes.ELEMENT || vnode.type !== (node as Element).tagName.toLowerCase() ) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + return onMismatch() } return hydrateElement( node as Element, @@ -159,7 +162,7 @@ export function createHydrationFunctions( : nextSibling(node) } else if (shapeFlag & ShapeFlags.PORTAL) { if (domType !== DOMNodeTypes.COMMENT) { - return handleMismtach(node, vnode, parentComponent, parentSuspense) + return onMismatch() } hydratePortal(vnode, parentComponent, parentSuspense, optimized) return nextSibling(node) @@ -247,7 +250,7 @@ export function createHydrationFunctions( // The SSRed DOM contains more nodes than it should. Remove them. const cur = next next = next.nextSibling - el.removeChild(cur) + remove(cur) } } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { if (el.textContent !== vnode.children) { @@ -321,18 +324,24 @@ export function createHydrationFunctions( optimized: boolean ) => { const container = parentNode(node)! - let next = hydrateChildren( + const next = hydrateChildren( nextSibling(node)!, vnode, container, parentComponent, parentSuspense, optimized - )! - if (__DEV__) { - next = replaceAnchor(next, container) + ) + if (next && isComment(next) && next.data === ']') { + 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 = ( @@ -366,7 +375,8 @@ export function createHydrationFunctions( node: Node, vnode: VNode, parentComponent: ComponentInternalInstance | null, - parentSuspense: SuspenseBoundary | null + parentSuspense: SuspenseBoundary | null, + isFragment: boolean ) => { hasMismatch = true __DEV__ && @@ -375,12 +385,31 @@ export function createHydrationFunctions( vnode.type, `\n- Server rendered DOM:`, node, - node.nodeType === DOMNodeTypes.TEXT ? `(text)` : `` + node.nodeType === DOMNodeTypes.TEXT + ? `(text)` + : isComment(node) && node.data === '[' + ? `(start of fragment)` + : `` ) 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 container = parentNode(node)! - container.removeChild(node) + remove(node) + patch( null, vnode, @@ -411,12 +440,5 @@ export function createHydrationFunctions( 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 }