diff --git a/packages/runtime-core/__tests__/hydration.spec.ts b/packages/runtime-core/__tests__/hydration.spec.ts index bf07c9773b3..4f5524212d1 100644 --- a/packages/runtime-core/__tests__/hydration.spec.ts +++ b/packages/runtime-core/__tests__/hydration.spec.ts @@ -1710,6 +1710,23 @@ describe('SSR hydration', () => { expect(`Hydration text content mismatch`).toHaveBeenWarned() }) + // #7775 + test('use unescapeHtml when vnode is of text type', () => { + const { container: styleContainer } = mountWithHydration( + ``, + () => h('style', '"test"'), + ) + expect(styleContainer.innerHTML).toBe('') + expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + + const { container: pContainer } = mountWithHydration( + `
"test"\n\r
`, + () => h('p', '"test"'), + ) + expect(pContainer.innerHTML).toBe('"test"
') + expect(`Hydration text content mismatch`).not.toHaveBeenWarned() + }) + test('not enough children', () => { const { container } = mountWithHydration(``, () => h('div', [h('span', 'foo'), h('span', 'bar')]), diff --git a/packages/runtime-core/src/hydration.ts b/packages/runtime-core/src/hydration.ts index b941635b2e9..67cda2cc833 100644 --- a/packages/runtime-core/src/hydration.ts +++ b/packages/runtime-core/src/hydration.ts @@ -30,6 +30,7 @@ import { normalizeClass, normalizeStyle, stringifyStyle, + unescapeHtml, } from '@vue/shared' import { type RendererInternals, needTransition } from './renderer' import { setRef } from './rendererTemplateRef' @@ -180,17 +181,24 @@ export function createHydrationFunctions( } } else { if ((node as Text).data !== vnode.children) { - ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && - warn( - `Hydration text mismatch in`, - node.parentNode, - `\n - rendered on server: ${JSON.stringify( - (node as Text).data, - )}` + - `\n - expected on client: ${JSON.stringify(vnode.children)}`, - ) - logMismatchError() - ;(node as Text).data = vnode.children as string + let dataContent = (node as Text).data.replace(/[\r\n]+/g, '') + let vnodeChildren = (vnode.children as string).replace( + /[\r\n]+/g, + '', + ) + if (unescapeHtml(dataContent) !== unescapeHtml(vnodeChildren)) { + ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && + warn( + `Hydration text mismatch in`, + node.parentNode, + `\n - rendered on server: ${JSON.stringify( + (node as Text).data, + )}` + + `\n - expected on client: ${JSON.stringify(vnode.children)}`, + ) + logMismatchError() + ;(node as Text).data = vnode.children as string + } } nextNode = nextSibling(node) } @@ -441,7 +449,12 @@ export function createHydrationFunctions( } } else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { if (el.textContent !== vnode.children) { - if (!isMismatchAllowed(el, MismatchTypes.TEXT)) { + let textContent = (el.textContent as string).replace(/[\r\n]+/g, '') + let vnodeChildren = (vnode.children as string).replace(/[\r\n]+/g, '') + if ( + !isMismatchAllowed(el, MismatchTypes.TEXT) && + unescapeHtml(textContent) !== unescapeHtml(vnodeChildren) + ) { ;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) && warn( `Hydration text content mismatch on`, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 11580a06435..f4f7d09913b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './normalizeProp' export * from './domTagConfig' export * from './domAttrConfig' export * from './escapeHtml' +export * from './unescapeHtml' export * from './looseEqual' export * from './toDisplayString' export * from './typeUtils' diff --git a/packages/shared/src/unescapeHtml.ts b/packages/shared/src/unescapeHtml.ts new file mode 100644 index 00000000000..3371781656f --- /dev/null +++ b/packages/shared/src/unescapeHtml.ts @@ -0,0 +1,24 @@ +const unescapeHtmlRE = /&\w+;|(\d+);/g +const UNESCAPE_HTML = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", +} +export function unescapeHtml(s: string | null) { + if (!s) return s + return s.replace(unescapeHtmlRE, function ($0: string, $1: number) { + let c = UNESCAPE_HTML[$0 as keyof typeof UNESCAPE_HTML] + if (c === undefined) { + // Maybe is Entity Number + if (!isNaN($1)) { + c = String.fromCharCode($1 === 160 ? 32 : $1) + } else { + // Not Entity Number + c = $0 + } + } + return c + }) +}