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(
+ '',
+ )
+ expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+ })
+
+ test('fragment mismatch removal', () => {
+ const { container } = mountWithHydration(
+ ``,
+ () => h('div', [h('span', 'replaced')]),
+ )
+ expect(container.innerHTML).toBe(
+ 'replaced
',
+ )
+ expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+ })
+
+ test('fragment not enough children', () => {
+ const { container } = mountWithHydration(
+ ``,
+ () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
+ )
+ expect(container.innerHTML).toBe(
+ '',
+ )
+ expect(`Hydration node mismatch`).not.toHaveBeenWarned()
+ })
+
+ test('fragment too many children', () => {
+ const { container } = mountWithHydration(
+ ``,
+ () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
+ )
+ expect(container.innerHTML).toBe(
+ '',
+ )
+ // 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])
+ }
+}