From 94fb2b8106a66bcca1a3f922a246a29fdd1274b1 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 25 Jul 2024 11:22:27 +0800 Subject: [PATCH] feat(hydration): support suppressing hydration mismatch via data-allow-mismatch --- .../runtime-core/__tests__/hydration.spec.ts | 132 +++++++++++++++ packages/runtime-core/src/hydration.ts | 153 ++++++++++++------ 2 files changed, 236 insertions(+), 49 deletions(-) diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index ed1e228c4cf..7024df03c00 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -1824,4 +1824,136 @@ describe('SSR hydration', () => { expect(`Hydration style mismatch`).not.toHaveBeenWarned() }) }) + + describe('data-allow-mismatch', () => { + test('element text content', () => { + const { container } = mountWithHydration( + `
foo
`, + () => h('div', 'bar'), + ) + expect(container.innerHTML).toBe( + '
bar
', + ) + expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + }) + + test('not enough children', () => { + const { container } = mountWithHydration( + `
`, + () => h('div', [h('span', 'foo'), h('span', 'bar')]), + ) + expect(container.innerHTML).toBe( + '
foobar
', + ) + expect(`Hydration children mismatch`).not.toHaveBeenWarned() + }) + + test('too many children', () => { + const { container } = mountWithHydration( + `
foobar
`, + () => h('div', [h('span', 'foo')]), + ) + expect(container.innerHTML).toBe( + '
foo
', + ) + expect(`Hydration children mismatch`).not.toHaveBeenWarned() + }) + + test('complete mismatch', () => { + const { container } = mountWithHydration( + `
foobar
`, + () => h('div', [h('div', 'foo'), h('p', 'bar')]), + ) + expect(container.innerHTML).toBe( + '
foo

bar

', + ) + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + }) + + test('fragment mismatch removal', () => { + const { container } = mountWithHydration( + `
foo
bar
`, + () => h('div', [h('span', 'replaced')]), + ) + expect(container.innerHTML).toBe( + '
replaced
', + ) + expect(`Hydration node mismatch`).not.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`).not.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`).not.toHaveBeenWarned() + // excessive children removal + expect(`Hydration children mismatch`).not.toHaveBeenWarned() + }) + + test('comment mismatch (element)', () => { + const { container } = mountWithHydration( + `
`, + () => h('div', [createCommentVNode('hi')]), + ) + expect(container.innerHTML).toBe( + '
', + ) + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + }) + + test('comment mismatch (text)', () => { + const { container } = mountWithHydration( + `
foobar
`, + () => h('div', [createCommentVNode('hi')]), + ) + expect(container.innerHTML).toBe( + '
', + ) + expect(`Hydration node mismatch`).not.toHaveBeenWarned() + }) + + test('class mismatch', () => { + mountWithHydration( + `
`, + () => h('div', { class: 'foo' }), + ) + expect(`Hydration class mismatch`).not.toHaveBeenWarned() + }) + + test('style mismatch', () => { + mountWithHydration( + `
`, + () => h('div', { style: { color: 'green' } }), + ) + expect(`Hydration style mismatch`).not.toHaveBeenWarned() + }) + + test('attr mismatch', () => { + mountWithHydration(`
`, () => + h('div', { id: 'foo' }), + ) + mountWithHydration( + `
`, + () => h('div', { id: 'foo' }), + ) + expect(`Hydration attribute mismatch`).not.toHaveBeenWarned() + }) + }) }) diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index cfb7703cee5..e79a9cede3d 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -405,18 +405,20 @@ export function createHydrationFunctions( ) let hasWarned = false while (next) { - if ( - (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && - !hasWarned - ) { - warn( - `Hydration children mismatch on`, - el, - `\nServer rendered element contains more child nodes than client vdom.`, - ) - hasWarned = true + if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) { + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + !hasWarned + ) { + warn( + `Hydration children mismatch on`, + el, + `\nServer rendered element contains more child nodes than client vdom.`, + ) + hasWarned = true + } + logMismatchError() } - logMismatchError() // The SSRed DOM contains more nodes than it should. Remove them. const cur = next @@ -425,14 +427,16 @@ export function createHydrationFunctions( } } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { if (el.textContent !== vnode.children) { - ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && - warn( - `Hydration text content mismatch on`, - el, - `\n - rendered on server: ${el.textContent}` + - `\n - expected on client: ${vnode.children as string}`, - ) - logMismatchError() + if (!isMismatchAllowed(el, MismatchTypes.TEXT)) { + ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + warn( + `Hydration text content mismatch on`, + el, + `\n - rendered on server: ${el.textContent}` + + `\n - expected on client: ${vnode.children as string}`, + ) + logMismatchError() + } el.textContent = vnode.children as string } @@ -562,18 +566,20 @@ export function createHydrationFunctions( // because server rendered HTML won't contain a text node insert((vnode.el = createText('')), container) } else { - if ( - (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && - !hasWarned - ) { - warn( - `Hydration children mismatch on`, - container, - `\nServer rendered element contains fewer child nodes than client vdom.`, - ) - hasWarned = true + if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) { + if ( + (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + !hasWarned + ) { + warn( + `Hydration children mismatch on`, + container, + `\nServer rendered element contains fewer child nodes than client vdom.`, + ) + hasWarned = true + } + logMismatchError() } - logMismatchError() // the SSRed DOM didn't contain enough nodes. Mount the missing ones. patch( @@ -637,19 +643,21 @@ export function createHydrationFunctions( slotScopeIds: string[] | null, isFragment: boolean, ): Node | null => { - ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && - warn( - `Hydration node mismatch:\n- rendered on server:`, - node, - node.nodeType === DOMNodeTypes.TEXT - ? `(text)` - : isComment(node) && node.data === '[' - ? `(start of fragment)` - : ``, - `\n- expected on client:`, - vnode.type, - ) - logMismatchError() + if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) { + ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + warn( + `Hydration node mismatch:\n- rendered on server:`, + node, + node.nodeType === DOMNodeTypes.TEXT + ? `(text)` + : isComment(node) && node.data === '[' + ? `(start of fragment)` + : ``, + `\n- expected on client:`, + vnode.type, + ) + logMismatchError() + } vnode.el = null @@ -747,7 +755,7 @@ function propHasMismatch( vnode: VNode, instance: ComponentInternalInstance | null, ): boolean { - let mismatchType: string | undefined + let mismatchType: MismatchTypes | undefined let mismatchKey: string | undefined let actual: string | boolean | null | undefined let expected: string | boolean | null | undefined @@ -757,7 +765,8 @@ function propHasMismatch( actual = el.getAttribute('class') expected = normalizeClass(clientValue) if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) { - mismatchType = mismatchKey = `class` + mismatchType = MismatchTypes.CLASS + mismatchKey = `class` } } else if (key === 'style') { // style might be in different order, but that doesn't affect cascade @@ -782,7 +791,8 @@ function propHasMismatch( } if (!isMapEqual(actualMap, expectedMap)) { - mismatchType = mismatchKey = 'style' + mismatchType = MismatchTypes.STYLE + mismatchKey = 'style' } } else if ( (el instanceof SVGElement && isKnownSvgAttr(key)) || @@ -808,15 +818,15 @@ function propHasMismatch( : false } if (actual !== expected) { - mismatchType = `attribute` + mismatchType = MismatchTypes.ATTRIBUTE mismatchKey = key } } - if (mismatchType) { + if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) { const format = (v: any) => v === false ? `(not rendered)` : `${mismatchKey}="${v}"` - const preSegment = `Hydration ${mismatchType} mismatch on` + const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on` const postSegment = `\n - rendered on server: ${format(actual)}` + `\n - expected on client: ${format(expected)}` + @@ -898,3 +908,48 @@ function resolveCssVars( resolveCssVars(instance.parent, instance.vnode, expectedMap) } } + +const allowMismatchAttr = 'data-allow-mismatch' + +enum MismatchTypes { + TEXT = 0, + CHILDREN = 1, + CLASS = 2, + STYLE = 3, + ATTRIBUTE = 4, +} + +const MismatchTypeString: Record = { + [MismatchTypes.TEXT]: 'text', + [MismatchTypes.CHILDREN]: 'children', + [MismatchTypes.CLASS]: 'class', + [MismatchTypes.STYLE]: 'style', + [MismatchTypes.ATTRIBUTE]: 'attribute', +} as const + +function isMismatchAllowed( + el: Element | null, + allowedType: MismatchTypes, +): boolean { + if ( + allowedType === MismatchTypes.TEXT || + allowedType === MismatchTypes.CHILDREN + ) { + while (el && !el.hasAttribute(allowMismatchAttr)) { + el = el.parentElement + } + } + const allowedAttr = el && el.getAttribute(allowMismatchAttr) + if (allowedAttr == null) { + return false + } else if (allowedAttr === '') { + return true + } else { + const list = allowedAttr.split(',') + // text is a subset of children + if (allowedType === MismatchTypes.TEXT && list.includes('children')) { + return true + } + return allowedAttr.split(',').includes(MismatchTypeString[allowedType]) + } +}