Skip to content

Commit

Permalink
feat(hydration): support suppressing hydration mismatch via data-allo…
Browse files Browse the repository at this point in the history
…w-mismatch
  • Loading branch information
yyx990803 committed Jul 25, 2024
1 parent 4ffd9db commit 94fb2b8
Show file tree
Hide file tree
Showing 2 changed files with 236 additions and 49 deletions.
132 changes: 132 additions & 0 deletions packages/runtime-core/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1824,4 +1824,136 @@ describe('SSR hydration', () => {
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
})

describe('data-allow-mismatch', () => {
test('element text content', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="text">foo</div>`,
() => h('div', 'bar'),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="text">bar</div>',
)
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
})

test('not enough children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"></div>`,
() => h('div', [h('span', 'foo'), h('span', 'bar')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>',
)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})

test('too many children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
() => h('div', [h('span', 'foo')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>foo</span></div>',
)
expect(`Hydration children mismatch`).not.toHaveBeenWarned()
})

test('complete mismatch', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><span>foo</span><span>bar</span></div>`,
() => h('div', [h('div', 'foo'), h('p', 'bar')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><div>foo</div><p>bar</p></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})

test('fragment mismatch removal', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
() => h('div', [h('span', 'replaced')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>replaced</span></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})

test('fragment not enough children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})

test('fragment too many children', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children"><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
() => h('div', [[h('div', 'foo')], h('div', 'baz')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--[--><div>foo</div><!--]--><div>baz</div></div>',
)
// fragment ends early and attempts to hydrate the extra <div>bar</div>
// 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(
`<div data-allow-mismatch="children"><span></span></div>`,
() => h('div', [createCommentVNode('hi')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--hi--></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})

test('comment mismatch (text)', () => {
const { container } = mountWithHydration(
`<div data-allow-mismatch="children">foobar</div>`,
() => h('div', [createCommentVNode('hi')]),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><!--hi--></div>',
)
expect(`Hydration node mismatch`).not.toHaveBeenWarned()
})

test('class mismatch', () => {
mountWithHydration(
`<div class="foo bar" data-allow-mismatch="class"></div>`,
() => h('div', { class: 'foo' }),
)
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
})

test('style mismatch', () => {
mountWithHydration(
`<div style="color:red;" data-allow-mismatch="style"></div>`,
() => h('div', { style: { color: 'green' } }),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})

test('attr mismatch', () => {
mountWithHydration(`<div data-allow-mismatch="attribute"></div>`, () =>
h('div', { id: 'foo' }),
)
mountWithHydration(
`<div id="bar" data-allow-mismatch="attribute"></div>`,
() => h('div', { id: 'foo' }),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
})
})
153 changes: 104 additions & 49 deletions packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)) ||
Expand All @@ -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)}` +
Expand Down Expand Up @@ -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, string> = {
[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])
}
}

0 comments on commit 94fb2b8

Please sign in to comment.