Skip to content

Commit 94fb2b8

Browse files
committed
feat(hydration): support suppressing hydration mismatch via data-allow-mismatch
1 parent 4ffd9db commit 94fb2b8

File tree

2 files changed

+236
-49
lines changed

2 files changed

+236
-49
lines changed

Diff for: packages/runtime-core/__tests__/hydration.spec.ts

+132
Original file line numberDiff line numberDiff line change
@@ -1824,4 +1824,136 @@ describe('SSR hydration', () => {
18241824
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
18251825
})
18261826
})
1827+
1828+
describe('data-allow-mismatch', () => {
1829+
test('element text content', () => {
1830+
const { container } = mountWithHydration(
1831+
`<div data-allow-mismatch="text">foo</div>`,
1832+
() => h('div', 'bar'),
1833+
)
1834+
expect(container.innerHTML).toBe(
1835+
'<div data-allow-mismatch="text">bar</div>',
1836+
)
1837+
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
1838+
})
1839+
1840+
test('not enough children', () => {
1841+
const { container } = mountWithHydration(
1842+
`<div data-allow-mismatch="children"></div>`,
1843+
() => h('div', [h('span', 'foo'), h('span', 'bar')]),
1844+
)
1845+
expect(container.innerHTML).toBe(
1846+
'<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>',
1847+
)
1848+
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
1849+
})
1850+
1851+
test('too many children', () => {
1852+
const { container } = mountWithHydration(
1853+
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
1854+
() => h('div', [h('span', 'foo')]),
1855+
)
1856+
expect(container.innerHTML).toBe(
1857+
'<div data-allow-mismatch="children"><span>foo</span></div>',
1858+
)
1859+
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
1860+
})
1861+
1862+
test('complete mismatch', () => {
1863+
const { container } = mountWithHydration(
1864+
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
1865+
() => h('div', [h('div', 'foo'), h('p', 'bar')]),
1866+
)
1867+
expect(container.innerHTML).toBe(
1868+
'<div data-allow-mismatch="children"><div>foo</div><p>bar</p></div>',
1869+
)
1870+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
1871+
})
1872+
1873+
test('fragment mismatch removal', () => {
1874+
const { container } = mountWithHydration(
1875+
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
1876+
() => h('div', [h('span', 'replaced')]),
1877+
)
1878+
expect(container.innerHTML).toBe(
1879+
'<div data-allow-mismatch="children"><span>replaced</span></div>',
1880+
)
1881+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
1882+
})
1883+
1884+
test('fragment not enough children', () => {
1885+
const { container } = mountWithHydration(
1886+
`<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
1887+
() => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
1888+
)
1889+
expect(container.innerHTML).toBe(
1890+
'<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
1891+
)
1892+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
1893+
})
1894+
1895+
test('fragment too many children', () => {
1896+
const { container } = mountWithHydration(
1897+
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
1898+
() => h('div', [[h('div', 'foo')], h('div', 'baz')]),
1899+
)
1900+
expect(container.innerHTML).toBe(
1901+
'<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
1902+
)
1903+
// fragment ends early and attempts to hydrate the extra <div>bar</div>
1904+
// as 2nd fragment child.
1905+
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
1906+
// excessive children removal
1907+
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
1908+
})
1909+
1910+
test('comment mismatch (element)', () => {
1911+
const { container } = mountWithHydration(
1912+
`<div data-allow-mismatch="children"><span></span></div>`,
1913+
() => h('div', [createCommentVNode('hi')]),
1914+
)
1915+
expect(container.innerHTML).toBe(
1916+
'<div data-allow-mismatch="children"><!--hi--></div>',
1917+
)
1918+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
1919+
})
1920+
1921+
test('comment mismatch (text)', () => {
1922+
const { container } = mountWithHydration(
1923+
`<div data-allow-mismatch="children">foobar</div>`,
1924+
() => h('div', [createCommentVNode('hi')]),
1925+
)
1926+
expect(container.innerHTML).toBe(
1927+
'<div data-allow-mismatch="children"><!--hi--></div>',
1928+
)
1929+
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
1930+
})
1931+
1932+
test('class mismatch', () => {
1933+
mountWithHydration(
1934+
`<div class="foo bar" data-allow-mismatch="class"></div>`,
1935+
() => h('div', { class: 'foo' }),
1936+
)
1937+
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
1938+
})
1939+
1940+
test('style mismatch', () => {
1941+
mountWithHydration(
1942+
`<div style="color:red;" data-allow-mismatch="style"></div>`,
1943+
() => h('div', { style: { color: 'green' } }),
1944+
)
1945+
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
1946+
})
1947+
1948+
test('attr mismatch', () => {
1949+
mountWithHydration(`<div data-allow-mismatch="attribute"></div>`, () =>
1950+
h('div', { id: 'foo' }),
1951+
)
1952+
mountWithHydration(
1953+
`<div id="bar" data-allow-mismatch="attribute"></div>`,
1954+
() => h('div', { id: 'foo' }),
1955+
)
1956+
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
1957+
})
1958+
})
18271959
})

Diff for: packages/runtime-core/src/hydration.ts

+104-49
Original file line numberDiff line numberDiff line change
@@ -405,18 +405,20 @@ export function createHydrationFunctions(
405405
)
406406
let hasWarned = false
407407
while (next) {
408-
if (
409-
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
410-
!hasWarned
411-
) {
412-
warn(
413-
`Hydration children mismatch on`,
414-
el,
415-
`\nServer rendered element contains more child nodes than client vdom.`,
416-
)
417-
hasWarned = true
408+
if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
409+
if (
410+
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
411+
!hasWarned
412+
) {
413+
warn(
414+
`Hydration children mismatch on`,
415+
el,
416+
`\nServer rendered element contains more child nodes than client vdom.`,
417+
)
418+
hasWarned = true
419+
}
420+
logMismatchError()
418421
}
419-
logMismatchError()
420422

421423
// The SSRed DOM contains more nodes than it should. Remove them.
422424
const cur = next
@@ -425,14 +427,16 @@ export function createHydrationFunctions(
425427
}
426428
} else if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
427429
if (el.textContent !== vnode.children) {
428-
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
429-
warn(
430-
`Hydration text content mismatch on`,
431-
el,
432-
`\n - rendered on server: ${el.textContent}` +
433-
`\n - expected on client: ${vnode.children as string}`,
434-
)
435-
logMismatchError()
430+
if (!isMismatchAllowed(el, MismatchTypes.TEXT)) {
431+
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
432+
warn(
433+
`Hydration text content mismatch on`,
434+
el,
435+
`\n - rendered on server: ${el.textContent}` +
436+
`\n - expected on client: ${vnode.children as string}`,
437+
)
438+
logMismatchError()
439+
}
436440

437441
el.textContent = vnode.children as string
438442
}
@@ -562,18 +566,20 @@ export function createHydrationFunctions(
562566
// because server rendered HTML won't contain a text node
563567
insert((vnode.el = createText('')), container)
564568
} else {
565-
if (
566-
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
567-
!hasWarned
568-
) {
569-
warn(
570-
`Hydration children mismatch on`,
571-
container,
572-
`\nServer rendered element contains fewer child nodes than client vdom.`,
573-
)
574-
hasWarned = true
569+
if (!isMismatchAllowed(container, MismatchTypes.CHILDREN)) {
570+
if (
571+
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
572+
!hasWarned
573+
) {
574+
warn(
575+
`Hydration children mismatch on`,
576+
container,
577+
`\nServer rendered element contains fewer child nodes than client vdom.`,
578+
)
579+
hasWarned = true
580+
}
581+
logMismatchError()
575582
}
576-
logMismatchError()
577583

578584
// the SSRed DOM didn't contain enough nodes. Mount the missing ones.
579585
patch(
@@ -637,19 +643,21 @@ export function createHydrationFunctions(
637643
slotScopeIds: string[] | null,
638644
isFragment: boolean,
639645
): Node | null => {
640-
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
641-
warn(
642-
`Hydration node mismatch:\n- rendered on server:`,
643-
node,
644-
node.nodeType === DOMNodeTypes.TEXT
645-
? `(text)`
646-
: isComment(node) && node.data === '['
647-
? `(start of fragment)`
648-
: ``,
649-
`\n- expected on client:`,
650-
vnode.type,
651-
)
652-
logMismatchError()
646+
if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
647+
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
648+
warn(
649+
`Hydration node mismatch:\n- rendered on server:`,
650+
node,
651+
node.nodeType === DOMNodeTypes.TEXT
652+
? `(text)`
653+
: isComment(node) && node.data === '['
654+
? `(start of fragment)`
655+
: ``,
656+
`\n- expected on client:`,
657+
vnode.type,
658+
)
659+
logMismatchError()
660+
}
653661

654662
vnode.el = null
655663

@@ -747,7 +755,7 @@ function propHasMismatch(
747755
vnode: VNode,
748756
instance: ComponentInternalInstance | null,
749757
): boolean {
750-
let mismatchType: string | undefined
758+
let mismatchType: MismatchTypes | undefined
751759
let mismatchKey: string | undefined
752760
let actual: string | boolean | null | undefined
753761
let expected: string | boolean | null | undefined
@@ -757,7 +765,8 @@ function propHasMismatch(
757765
actual = el.getAttribute('class')
758766
expected = normalizeClass(clientValue)
759767
if (!isSetEqual(toClassSet(actual || ''), toClassSet(expected))) {
760-
mismatchType = mismatchKey = `class`
768+
mismatchType = MismatchTypes.CLASS
769+
mismatchKey = `class`
761770
}
762771
} else if (key === 'style') {
763772
// style might be in different order, but that doesn't affect cascade
@@ -782,7 +791,8 @@ function propHasMismatch(
782791
}
783792

784793
if (!isMapEqual(actualMap, expectedMap)) {
785-
mismatchType = mismatchKey = 'style'
794+
mismatchType = MismatchTypes.STYLE
795+
mismatchKey = 'style'
786796
}
787797
} else if (
788798
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
@@ -808,15 +818,15 @@ function propHasMismatch(
808818
: false
809819
}
810820
if (actual !== expected) {
811-
mismatchType = `attribute`
821+
mismatchType = MismatchTypes.ATTRIBUTE
812822
mismatchKey = key
813823
}
814824
}
815825

816-
if (mismatchType) {
826+
if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
817827
const format = (v: any) =>
818828
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
819-
const preSegment = `Hydration ${mismatchType} mismatch on`
829+
const preSegment = `Hydration ${MismatchTypeString[mismatchType]} mismatch on`
820830
const postSegment =
821831
`\n - rendered on server: ${format(actual)}` +
822832
`\n - expected on client: ${format(expected)}` +
@@ -898,3 +908,48 @@ function resolveCssVars(
898908
resolveCssVars(instance.parent, instance.vnode, expectedMap)
899909
}
900910
}
911+
912+
const allowMismatchAttr = 'data-allow-mismatch'
913+
914+
enum MismatchTypes {
915+
TEXT = 0,
916+
CHILDREN = 1,
917+
CLASS = 2,
918+
STYLE = 3,
919+
ATTRIBUTE = 4,
920+
}
921+
922+
const MismatchTypeString: Record<MismatchTypes, string> = {
923+
[MismatchTypes.TEXT]: 'text',
924+
[MismatchTypes.CHILDREN]: 'children',
925+
[MismatchTypes.CLASS]: 'class',
926+
[MismatchTypes.STYLE]: 'style',
927+
[MismatchTypes.ATTRIBUTE]: 'attribute',
928+
} as const
929+
930+
function isMismatchAllowed(
931+
el: Element | null,
932+
allowedType: MismatchTypes,
933+
): boolean {
934+
if (
935+
allowedType === MismatchTypes.TEXT ||
936+
allowedType === MismatchTypes.CHILDREN
937+
) {
938+
while (el && !el.hasAttribute(allowMismatchAttr)) {
939+
el = el.parentElement
940+
}
941+
}
942+
const allowedAttr = el && el.getAttribute(allowMismatchAttr)
943+
if (allowedAttr == null) {
944+
return false
945+
} else if (allowedAttr === '') {
946+
return true
947+
} else {
948+
const list = allowedAttr.split(',')
949+
// text is a subset of children
950+
if (allowedType === MismatchTypes.TEXT && list.includes('children')) {
951+
return true
952+
}
953+
return allowedAttr.split(',').includes(MismatchTypeString[allowedType])
954+
}
955+
}

0 commit comments

Comments
 (0)