chore: hydration mismatch handling

This commit is contained in:
daiwei 2025-09-22 14:16:50 +08:00
parent c272550f85
commit c42499c3f0
8 changed files with 1072 additions and 78 deletions

View File

@ -869,35 +869,61 @@ function propHasMismatch(
mismatchType = MismatchTypes.STYLE
mismatchKey = 'style'
}
} else if (
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
) {
if (isBooleanAttr(key)) {
actual = el.hasAttribute(key)
expected = includeBooleanAttr(clientValue)
} else if (clientValue == null) {
actual = el.hasAttribute(key)
expected = false
} else {
if (el.hasAttribute(key)) {
actual = el.getAttribute(key)
} else if (key === 'value' && el.tagName === 'TEXTAREA') {
// #10000 textarea.value can't be retrieved by `hasAttribute`
actual = (el as HTMLTextAreaElement).value
} else {
actual = false
}
expected = isRenderableAttrValue(clientValue)
? String(clientValue)
: false
}
} else if (isValidHtmlOrSvgAttribute(el, key)) {
;({ actual, expected } = getAttributeMismatch(el, key, clientValue))
if (actual !== expected) {
mismatchType = MismatchTypes.ATTRIBUTE
mismatchKey = key
}
}
return warnPropMismatch(el, mismatchKey, mismatchType, actual, expected)
}
export function getAttributeMismatch(
el: Element,
key: string,
clientValue: any,
): {
actual: string | boolean | null | undefined
expected: string | boolean | null | undefined
} {
let actual: string | boolean | null | undefined
let expected: string | boolean | null | undefined
if (isBooleanAttr(key)) {
actual = el.hasAttribute(key)
expected = includeBooleanAttr(clientValue)
} else if (clientValue == null) {
actual = el.hasAttribute(key)
expected = false
} else {
if (el.hasAttribute(key)) {
actual = el.getAttribute(key)
} else if (key === 'value' && el.tagName === 'TEXTAREA') {
// #10000 textarea.value can't be retrieved by `hasAttribute`
actual = (el as HTMLTextAreaElement).value
} else {
actual = false
}
expected = isRenderableAttrValue(clientValue) ? String(clientValue) : false
}
return { actual, expected }
}
export function isValidHtmlOrSvgAttribute(el: Element, key: string): boolean {
return (
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
)
}
export function warnPropMismatch(
el: Element & { $cls?: string },
mismatchKey: string | undefined,
mismatchType: MismatchTypes | undefined,
actual: string | boolean | null | undefined,
expected: string | boolean | null | undefined,
): boolean {
if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
const format = (v: any) =>
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
@ -920,11 +946,11 @@ function propHasMismatch(
return false
}
function toClassSet(str: string): Set<string> {
export function toClassSet(str: string): Set<string> {
return new Set(str.trim().split(/\s+/))
}
function isSetEqual(a: Set<string>, b: Set<string>): boolean {
export function isSetEqual(a: Set<string>, b: Set<string>): boolean {
if (a.size !== b.size) {
return false
}
@ -936,7 +962,7 @@ function isSetEqual(a: Set<string>, b: Set<string>): boolean {
return true
}
function toStyleMap(str: string): Map<string, string> {
export function toStyleMap(str: string): Map<string, string> {
const styleMap: Map<string, string> = new Map()
for (const item of str.split(';')) {
let [key, value] = item.split(':')
@ -949,7 +975,10 @@ function toStyleMap(str: string): Map<string, string> {
return styleMap
}
function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
export function isMapEqual(
a: Map<string, string>,
b: Map<string, string>,
): boolean {
if (a.size !== b.size) {
return false
}
@ -991,7 +1020,7 @@ function resolveCssVars(
const allowMismatchAttr = 'data-allow-mismatch'
enum MismatchTypes {
export enum MismatchTypes {
TEXT = 0,
CHILDREN = 1,
CLASS = 2,
@ -1007,7 +1036,7 @@ const MismatchTypeString: Record<MismatchTypes, string> = {
[MismatchTypes.ATTRIBUTE]: 'attribute',
} as const
function isMismatchAllowed(
export function isMismatchAllowed(
el: Element | null,
allowedType: MismatchTypes,
): boolean {

View File

@ -597,6 +597,20 @@ export { markAsyncBoundary } from './helpers/useId'
* @internal
*/
export { createInternalObject } from './internalObject'
/**
* @internal
*/
export {
MismatchTypes,
isMismatchAllowed,
toClassSet,
isSetEqual,
warnPropMismatch,
toStyleMap,
isMapEqual,
isValidHtmlOrSvgAttribute,
getAttributeMismatch,
} from './hydration'
/**
* @internal
*/

View File

@ -77,6 +77,26 @@ async function testWithVDOMApp(
})
}
async function mountWithHydration(
html: string,
code: string,
data: runtimeDom.Ref<any>,
) {
const container = document.createElement('div')
container.innerHTML = html
const clientComp = compile(`<template>${code}</template>`, data, undefined, {
vapor: true,
ssr: false,
})
const app = createVaporSSRApp(clientComp)
app.mount(container)
return {
container,
}
}
async function testHydration(
code: string,
components: Record<string, string | { code: string; vapor: boolean }> = {},
@ -233,7 +253,7 @@ describe('Vapor Mode hydration', () => {
data,
)
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
`"<div> </div>"`,
`"<div></div>"`,
)
data.txt = 'foo'
@ -255,7 +275,7 @@ describe('Vapor Mode hydration', () => {
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
`
"
<!--[--> <!--]-->
<!--[--><!--]-->
"
`,
)
@ -2900,15 +2920,649 @@ describe('Vapor Mode hydration', () => {
test.todo('force hydrate custom element with dynamic props', () => {})
})
describe.todo('data-allow-mismatch')
describe.todo('mismatch handling')
describe.todo('Teleport')
describe.todo('Suspense')
})
describe('mismatch handling', () => {
test('text node', async () => {
const foo = ref('bar')
const { container } = await mountWithHydration(`foo`, `{{data}}`, foo)
expect(container.textContent).toBe('bar')
expect(`Hydration text mismatch`).toHaveBeenWarned()
})
test('element text content', async () => {
const data = ref({ textContent: 'bar' })
const { container } = await mountWithHydration(
`<div>foo</div>`,
`<div v-bind="data"></div>`,
data,
)
expect(container.innerHTML).toBe('<div>bar</div>')
expect(`Hydration text content mismatch`).toHaveBeenWarned()
})
test('element with v-html', async () => {
const data = ref('<p>bar</p>')
const { container } = await mountWithHydration(
`<div><p>foo</p></div>`,
`<div v-html="data"></div>`,
data,
)
expect(container.innerHTML).toBe('<div><p>bar</p></div>')
expect(`Hydration children mismatch on`).toHaveBeenWarned()
})
// test('not enough children', () => {
// const { container } = mountWithHydration(`<div></div>`, () =>
// h('div', [h('span', 'foo'), h('span', 'bar')]),
// )
// expect(container.innerHTML).toBe(
// '<div><span>foo</span><span>bar</span></div>',
// )
// expect(`Hydration children mismatch`).toHaveBeenWarned()
// })
// test('too many children', () => {
// const { container } = mountWithHydration(
// `<div><span>foo</span><span>bar</span></div>`,
// () => h('div', [h('span', 'foo')]),
// )
// expect(container.innerHTML).toBe('<div><span>foo</span></div>')
// expect(`Hydration children mismatch`).toHaveBeenWarned()
// })
test('complete mismatch', async () => {
const data = ref('span')
const { container } = await mountWithHydration(
`<div>foo</div>`,
`<component :is="data">foo</component>`,
data,
)
expect(container.innerHTML).toBe('<span>foo</span><!--dynamic-component-->')
expect(`Hydration node mismatch`).toHaveBeenWarned()
})
// test('fragment mismatch removal', () => {
// const { container } = mountWithHydration(
// `<div><!--[--><div>foo</div><div>bar</div><!--]--></div>`,
// () => h('div', [h('span', 'replaced')]),
// )
// expect(container.innerHTML).toBe('<div><span>replaced</span></div>')
// expect(`Hydration node mismatch`).toHaveBeenWarned()
// })
// test('fragment not enough children', () => {
// const { container } = mountWithHydration(
// `<div><!--[--><div>foo</div><!--]--><div>baz</div></div>`,
// () => h('div', [[h('div', 'foo'), h('div', 'bar')], h('div', 'baz')]),
// )
// expect(container.innerHTML).toBe(
// '<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>',
// )
// expect(`Hydration node mismatch`).toHaveBeenWarned()
// })
// test('fragment too many children', () => {
// const { container } = mountWithHydration(
// `<div><!--[--><div>foo</div><div>bar</div><!--]--><div>baz</div></div>`,
// () => h('div', [[h('div', 'foo')], h('div', 'baz')]),
// )
// expect(container.innerHTML).toBe(
// '<div><!--[--><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`).toHaveBeenWarned()
// // excessive children removal
// expect(`Hydration children mismatch`).toHaveBeenWarned()
// })
// test('Teleport target has empty children', () => {
// const teleportContainer = document.createElement('div')
// teleportContainer.id = 'teleport'
// document.body.appendChild(teleportContainer)
// mountWithHydration('<!--teleport start--><!--teleport end-->', () =>
// h(Teleport, { to: '#teleport' }, [h('span', 'value')]),
// )
// expect(teleportContainer.innerHTML).toBe(`<span>value</span>`)
// expect(`Hydration children mismatch`).toHaveBeenWarned()
// })
// test('comment mismatch (element)', () => {
// const { container } = mountWithHydration(`<div><span></span></div>`, () =>
// h('div', [createCommentVNode('hi')]),
// )
// expect(container.innerHTML).toBe('<div><!--hi--></div>')
// expect(`Hydration node mismatch`).toHaveBeenWarned()
// })
// test('comment mismatch (text)', () => {
// const { container } = mountWithHydration(`<div>foobar</div>`, () =>
// h('div', [createCommentVNode('hi')]),
// )
// expect(container.innerHTML).toBe('<div><!--hi--></div>')
// expect(`Hydration node mismatch`).toHaveBeenWarned()
// })
test('class mismatch', async () => {
await mountWithHydration(
`<div class="foo bar"></div>`,
`<div :class="data"></div>`,
ref(['foo', 'bar']),
)
await mountWithHydration(
`<div class="foo bar"></div>`,
`<div :class="data"></div>`,
ref({ foo: true, bar: true }),
)
await mountWithHydration(
`<div class="foo bar"></div>`,
`<div :class="data"></div>`,
ref('foo bar'),
)
// svg classes
await mountWithHydration(
`<svg class="foo bar"></svg>`,
`<svg :class="data"></svg>`,
ref('foo bar'),
)
// class with different order
await mountWithHydration(
`<div class="foo bar"></div>`,
`<div :class="data"></div>`,
ref('bar foo'),
)
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
// single root mismatch
const { container: root } = await mountWithHydration(
`<div class="foo bar"></div>`,
`<div :class="data"></div>`,
ref('baz'),
)
expect(root.innerHTML).toBe('<div class="foo bar baz"></div>')
expect(`Hydration class mismatch`).toHaveBeenWarned()
// multiple root mismatch
const { container } = await mountWithHydration(
`<div class="foo bar"></div><span/>`,
`<div :class="data"></div><span/>`,
ref('foo'),
)
expect(container.innerHTML).toBe('<div class="foo"></div><span></span>')
expect(`Hydration class mismatch`).toHaveBeenWarned()
})
test('style mismatch', async () => {
await mountWithHydration(
`<div style="color:red;"></div>`,
`<div :style="data"></div>`,
ref({ color: 'red' }),
)
await mountWithHydration(
`<div style="color:red;"></div>`,
`<div :style="data"></div>`,
ref('color:red;'),
)
// style with different order
await mountWithHydration(
`<div style="color:red; font-size: 12px;"></div>`,
`<div :style="data"></div>`,
ref(`font-size: 12px; color:red;`),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
// single root mismatch
const { container: root } = await mountWithHydration(
`<div style="color:red;"></div>`,
`<div :style="data"></div>`,
ref({ color: 'green' }),
)
expect(root.innerHTML).toBe('<div style="color: green;"></div>')
expect(`Hydration style mismatch`).toHaveBeenWarned()
// multiple root mismatch
const { container } = await mountWithHydration(
`<div style="color:red;"></div><span/>`,
`<div :style="data"></div><span/>`,
ref({ color: 'green' }),
)
expect(container.innerHTML).toBe(
'<div style="color: green;"></div><span></span>',
)
expect(`Hydration style mismatch`).toHaveBeenWarned()
})
test('style mismatch when no style attribute is present', async () => {
await mountWithHydration(
`<div></div>`,
`<div :style="data"></div>`,
ref({ color: 'red' }),
)
expect(`Hydration style mismatch`).toHaveBeenWarnedTimes(1)
})
test('style mismatch w/ v-show', async () => {
await mountWithHydration(
`<div style="color:red;display:none"></div>`,
`<div v-show="data" style="color: red;"></div>`,
ref(false),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
// mismatch with single root
const { container: root } = await mountWithHydration(
`<div style="color:red;"></div>`,
`<div v-show="data" style="color: red;"></div>`,
ref(false),
)
expect(root.innerHTML).toBe(
'<div style="color: red; display: none;"></div>',
)
expect(`Hydration style mismatch`).toHaveBeenWarned()
// mismatch with multiple root
const { container } = await mountWithHydration(
`<div style="color:red;"></div><span/>`,
`<div v-show="data.show" :style="data.style"></div><span/>`,
ref({ show: false, style: 'color: red' }),
)
expect(container.innerHTML).toBe(
'<div style="color: red; display: none;"></div><span></span>',
)
expect(`Hydration style mismatch`).toHaveBeenWarned()
})
test('attr mismatch', async () => {
await mountWithHydration(
`<div id="foo"></div>`,
`<div :id="data"></div>`,
ref('foo'),
)
await mountWithHydration(
`<div spellcheck></div>`,
`<div :spellcheck="data"></div>`,
ref(''),
)
await mountWithHydration(
`<div></div>`,
`<div :id="data"></div>`,
ref(undefined),
)
// boolean
await mountWithHydration(
`<select multiple></div>`,
`<select :multiple="data"></select>`,
ref(true),
)
await mountWithHydration(
`<select multiple></div>`,
`<select :multiple="data"></select>`,
ref('multiple'),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
await mountWithHydration(
`<div></div>`,
`<div :id="data"></div>`,
ref('foo'),
)
expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(1)
await mountWithHydration(
`<div id="bar"></div>`,
`<div :id="data"></div>`,
ref('foo'),
)
expect(`Hydration attribute mismatch`).toHaveBeenWarnedTimes(2)
})
test('attr special case: textarea value', async () => {
await mountWithHydration(
`<textarea>foo</textarea>`,
`<textarea :value="data"></textarea>`,
ref('foo'),
)
await mountWithHydration(
`<textarea></textarea>`,
`<textarea :value="data"></textarea>`,
ref(''),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
await mountWithHydration(
`<textarea>foo</textarea>`,
`<textarea :value="data"></textarea>`,
ref('bar'),
)
expect(`Hydration attribute mismatch`).toHaveBeenWarned()
})
test('<textarea> with newlines at the beginning', async () => {
await mountWithHydration(
`<textarea>\nhello</textarea>`,
`<textarea :value="data"></textarea>`,
ref('\nhello'),
)
await mountWithHydration(
`<textarea>\nhello</textarea>`,
`<textarea v-text="data"></textarea>`,
ref('\nhello'),
)
await mountWithHydration(
`<textarea>\nhello</textarea>`,
`<textarea v-bind="data"></textarea>`,
ref({ textContent: '\nhello' }),
)
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
})
test('<pre> with newlines at the beginning', async () => {
await mountWithHydration(`<pre>\n</pre>`, `<pre>{{data}}</pre>`, ref('\n'))
await mountWithHydration(
`<pre>\n</pre>`,
`<pre v-text="data"></pre>`,
ref('\n'),
)
await mountWithHydration(
`<pre>\n</pre>`,
`<pre v-bind="data"></pre>`,
ref({ textContent: '\n' }),
)
expect(`Hydration text content mismatch`).not.toHaveBeenWarned()
})
test('boolean attr handling', async () => {
await mountWithHydration(
`<input />`,
`<input :readonly="data" />`,
ref(false),
)
await mountWithHydration(
`<input readonly />`,
`<input :readonly="data" />`,
ref(true),
)
await mountWithHydration(
`<input readonly="readonly" />`,
`<input :readonly="data" />`,
ref(true),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test('client value is null or undefined', async () => {
await mountWithHydration(
`<div></div>`,
`<div :draggable="data"></div>`,
ref(undefined),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
await mountWithHydration(`<input />`, `<input :type="data" />`, ref(null))
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test('should not warn against object values', async () => {
await mountWithHydration(`<input />`, `<input :from="data" />`, ref({}))
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test('should not warn on falsy bindings of non-property keys', async () => {
await mountWithHydration(
`<button></button>`,
`<button :href="data"></button>`,
ref(undefined),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test('should not warn on non-renderable option values', async () => {
await mountWithHydration(
`<select><option>hello</option></select>`,
`<select><option :value="data">hello</option></select>`,
ref(['foo']),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
test.todo('should not warn css v-bind', () => {
// const container = document.createElement('div')
// container.innerHTML = `<div style="--foo:red;color:var(--foo);" />`
// const app = createSSRApp({
// setup() {
// useCssVars(() => ({
// foo: 'red',
// }))
// return () => h('div', { style: { color: 'var(--foo)' } })
// },
// })
// app.mount(container)
// expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
test.todo(
'css vars should only be added to expected on component root dom',
() => {
// const container = document.createElement('div')
// container.innerHTML = `<div style="--foo:red;"><div style="color:var(--foo);" /></div>`
// const app = createSSRApp({
// setup() {
// useCssVars(() => ({
// foo: 'red',
// }))
// return () =>
// h('div', null, [h('div', { style: { color: 'var(--foo)' } })])
// },
// })
// app.mount(container)
// expect(`Hydration style mismatch`).not.toHaveBeenWarned()
},
)
test.todo('css vars support fallthrough', () => {
// const container = document.createElement('div')
// container.innerHTML = `<div style="padding: 4px;--foo:red;"></div>`
// const app = createSSRApp({
// setup() {
// useCssVars(() => ({
// foo: 'red',
// }))
// return () => h(Child)
// },
// })
// const Child = {
// setup() {
// return () => h('div', { style: 'padding: 4px' })
// },
// }
// app.mount(container)
// expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
// vapor directive does not have a created hook
test('should not warn for directives that mutate DOM in created', () => {
// const container = document.createElement('div')
// container.innerHTML = `<div class="test red"></div>`
// const vColor: ObjectDirective = {
// created(el, binding) {
// el.classList.add(binding.value)
// },
// }
// const app = createSSRApp({
// setup() {
// return () =>
// withDirectives(h('div', { class: 'test' }), [[vColor, 'red']])
// },
// })
// app.mount(container)
// expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
test.todo('escape css var name', () => {
// const container = document.createElement('div')
// container.innerHTML = `<div style="padding: 4px;--foo\\.bar:red;"></div>`
// const app = createSSRApp({
// setup() {
// useCssVars(() => ({
// 'foo.bar': 'red',
// }))
// return () => h(Child)
// },
// })
// const Child = {
// setup() {
// return () => h('div', { style: 'padding: 4px' })
// },
// }
// app.mount(container)
// expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
})
describe('data-allow-mismatch', () => {
test('element text content', async () => {
const data = ref({ textContent: 'bar' })
const { container } = await mountWithHydration(
`<div data-allow-mismatch="text">foo</div>`,
`<div v-bind="data"></div>`,
data,
)
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', async () => {
const { container } = await mountWithHydration(
`<div data-allow-mismatch="children"><div>foo</div></div>`,
`<div><component :is="data">foo</component></div>`,
ref('span'),
)
expect(container.innerHTML).toBe(
'<div data-allow-mismatch="children"><span>foo</span><!--dynamic-component--></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', async () => {
await mountWithHydration(
`<div class="foo bar" data-allow-mismatch="class"></div>`,
`<div :class="data"></div>`,
ref('foo'),
)
expect(`Hydration class mismatch`).not.toHaveBeenWarned()
})
test('style mismatch', async () => {
await mountWithHydration(
`<div style="color:red;" data-allow-mismatch="style"></div>`,
`<div :style="data"></div>`,
ref({ color: 'green' }),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
})
test('attr mismatch', async () => {
await mountWithHydration(
`<div data-allow-mismatch="attribute"></div>`,
`<div :id="data"></div>`,
ref('foo'),
)
await mountWithHydration(
`<div id="bar" data-allow-mismatch="attribute"></div>`,
`<div :id="data"></div>`,
ref('foo'),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()
})
})
describe('VDOM interop', () => {
test('basic render vapor component', async () => {
const data = ref(true)

View File

@ -1,14 +1,17 @@
import {
MismatchTypes,
type VShowElement,
vShowHidden,
vShowOriginalDisplay,
warn,
warnPropMismatch,
} from '@vue/runtime-dom'
import { renderEffect } from '../renderEffect'
import { isVaporComponent } from '../component'
import type { Block, TransitionBlock } from '../block'
import { isArray } from '@vue/shared'
import { DynamicFragment, VaporFragment } from '../fragment'
import { isHydrating, logMismatchError } from '../dom/hydration'
export function applyVShow(target: Block, source: () => any): void {
if (isVaporComponent(target)) {
@ -74,9 +77,29 @@ function setDisplay(target: Block, value: unknown): void {
}
}
} else {
el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating
) {
if (!value && el.style.display !== 'none') {
warnPropMismatch(
el,
'style',
MismatchTypes.STYLE,
`display: ${el.style.display}`,
'display: none',
)
logMismatchError()
el.style.display = 'none'
el[vShowOriginalDisplay] = ''
}
} else {
el.style.display = value ? el[vShowOriginalDisplay]! : 'none'
}
el[vShowHidden] = !value
}
el[vShowHidden] = !value
} else if (__DEV__) {
warn(
`v-show used on component with non-single-element root node ` +

View File

@ -1,4 +1,4 @@
import { warn } from '@vue/runtime-dom'
import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom'
import {
type ChildItem,
incrementIndexOffset,
@ -8,10 +8,15 @@ import {
setInsertionState,
} from '../insertionState'
import {
_next,
child,
createElement,
createTextNode,
disableHydrationNodeLookup,
enableHydrationNodeLookup,
parentNode,
} from './node'
import { remove } from '../block'
const isHydratingStack = [] as boolean[]
export let isHydrating = false
@ -113,28 +118,23 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
isComment(node, ']') &&
isComment(node.previousSibling!, '[')
) {
node = node.parentNode!.insertBefore(createTextNode(' '), node)
incrementIndexOffset(node.parentNode!)
const parent = parentNode(node)!
node = parent.insertBefore(createTextNode(), node)
incrementIndexOffset(parent)
break
}
}
}
if (__DEV__) {
const type = node.nodeType
if (
(type === 8 && !template.startsWith('<!')) ||
(type === 1 &&
!template.startsWith(`<` + (node as Element).tagName.toLowerCase())) ||
(type === 3 &&
template.trim() &&
!template.startsWith((node as Text).data))
) {
// TODO recover and provide more info
warn(`adopted: `, node)
warn(`template: ${template}`)
warn('hydration mismatch!')
}
const type = node.nodeType
if (
// comment node
(type === 8 && !template.startsWith('<!')) ||
// element node
(type === 1 &&
!template.startsWith(`<` + (node as Element).tagName.toLowerCase()))
) {
node = handleMismatch(node, template)
}
advanceHydrationNode(node)
@ -210,8 +210,10 @@ function locateHydrationNodeImpl(): void {
}
if (__DEV__ && !node) {
// TODO more info
warn('Hydration mismatch in ', insertionParent)
throw new Error(
`No current hydration node was found.\n` +
`this is likely a Vue internal bug.`,
)
}
resetInsertionState()
@ -252,3 +254,64 @@ export function locateFragmentEndAnchor(label: string = ']'): Comment | null {
}
return null
}
function handleMismatch(node: Node, template: string): Node {
if (!isMismatchAllowed(node.parentElement!, MismatchTypes.CHILDREN)) {
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration node mismatch:\n- rendered on server:`,
node,
node.nodeType === 3
? `(text)`
: isComment(node, '[[')
? `(start of block node)`
: ``,
`\n- expected on client:`,
template,
)
logMismatchError()
}
// fragment
if (isComment(node, '[')) {
const end = locateEndAnchor(node as Anchor)
while (true) {
const next = _next(node)
if (next && next !== end) {
remove(next, parentNode(node)!)
} else {
break
}
}
}
const next = _next(node)
const container = parentNode(node)!
remove(node, container)
// fast path for text nodes
if (template[0] !== '<') {
return container.insertBefore(createTextNode(template), next)
}
// element node
const t = createElement('template') as HTMLTemplateElement
t.innerHTML = template
const newNode = child(t.content).cloneNode(true) as Element
newNode.innerHTML = (node as Element).innerHTML
Array.from((node as Element).attributes).forEach(attr => {
newNode.setAttribute(attr.name, attr.value)
})
container.insertBefore(newNode, next)
return newNode
}
let hasLoggedMismatchError = false
export const logMismatchError = (): void => {
if (__TEST__ || hasLoggedMismatchError) {
return
}
// this error should show up in production
console.error('Hydration completed but contains mismatches.')
hasLoggedMismatchError = true
}

View File

@ -21,6 +21,11 @@ export function querySelector(selectors: string): Element | null {
return document.querySelector(selectors)
}
/*! @__NO_SIDE_EFFECTS__ */
export function parentNode(node: Node): ParentNode | null {
return node.parentNode
}
/* @__NO_SIDE_EFFECTS__ */
const _txt: typeof _child = _child
@ -34,8 +39,7 @@ const __txt: typeof __child = (node: ParentNode): Node => {
// since SSR doesn't generate whitespace placeholder text nodes, if firstChild
// is null, manually insert a text node as the first child
if (!n) {
node.textContent = ' '
return node.firstChild!
return node.appendChild(createTextNode())
}
return n

View File

@ -6,20 +6,32 @@ import {
normalizeClass,
normalizeStyle,
parseStringStyle,
stringifyStyle,
toDisplayString,
} from '@vue/shared'
import { on } from './event'
import {
MismatchTypes,
currentInstance,
getAttributeMismatch,
isMapEqual,
isMismatchAllowed,
isSetEqual,
isValidHtmlOrSvgAttribute,
mergeProps,
patchStyle,
shouldSetAsProp,
toClassSet,
toStyleMap,
vShowHidden,
warn,
warnPropMismatch,
} from '@vue/runtime-dom'
import {
type VaporComponentInstance,
isApplyingFallthroughProps,
} from '../component'
import { isHydrating, logMismatchError } from './hydration'
type TargetElement = Element & {
$root?: true
@ -57,6 +69,15 @@ export function setAttr(el: any, key: string, value: any): void {
;(el as any)._falseValue = value
}
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!attributeHasMismatch(el, key, value)
) {
el[`$${key}`] = value
return
}
if (value !== el[`$${key}`]) {
el[`$${key}`] = value
if (value != null) {
@ -72,6 +93,14 @@ export function setDOMProp(el: any, key: string, value: any): void {
return
}
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!attributeHasMismatch(el, key, value)
) {
return
}
const prev = el[key]
if (value === prev) {
return
@ -112,15 +141,38 @@ export function setDOMProp(el: any, key: string, value: any): void {
export function setClass(el: TargetElement, value: any): void {
if (el.$root) {
setClassIncremental(el, value)
} else if ((value = normalizeClass(value)) !== el.$cls) {
el.className = el.$cls = value
} else {
value = normalizeClass(value)
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!classHasMismatch(el, value, false)
) {
el.$cls = value
return
}
if (value !== el.$cls) {
el.className = el.$cls = value
}
}
}
function setClassIncremental(el: any, value: any): void {
const cacheKey = `$clsi${isApplyingFallthroughProps ? '$' : ''}`
const normalizedValue = normalizeClass(value)
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!classHasMismatch(el, normalizedValue, true)
) {
el[cacheKey] = normalizedValue
return
}
const prev = el[cacheKey]
if ((value = el[cacheKey] = normalizeClass(value)) !== prev) {
if ((value = el[cacheKey] = normalizedValue) !== prev) {
const nextList = value.split(/\s+/)
if (value) {
el.classList.add(...nextList)
@ -137,20 +189,36 @@ export function setStyle(el: TargetElement, value: any): void {
if (el.$root) {
setStyleIncremental(el, value)
} else {
const prev = el.$sty
value = el.$sty = normalizeStyle(value)
patchStyle(el, prev, value)
const normalizedValue = normalizeStyle(value)
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!styleHasMismatch(el, value, normalizedValue, false)
) {
el.$sty = normalizedValue
return
}
patchStyle(el, el.$sty, (el.$sty = normalizedValue))
}
}
function setStyleIncremental(el: any, value: any): NormalizedStyle | undefined {
const cacheKey = `$styi${isApplyingFallthroughProps ? '$' : ''}`
const prev = el[cacheKey]
value = el[cacheKey] = isString(value)
const normalizedValue = isString(value)
? parseStringStyle(value)
: (normalizeStyle(value) as NormalizedStyle | undefined)
patchStyle(el, prev, value)
return value
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!styleHasMismatch(el, value, normalizedValue, true)
) {
el[cacheKey] = normalizedValue
return
}
patchStyle(el, el[cacheKey], (el[cacheKey] = normalizedValue))
}
export function setValue(el: TargetElement, value: any): void {
@ -161,6 +229,15 @@ export function setValue(el: TargetElement, value: any): void {
// store value as _value as well since
// non-string values will be stringified.
el._value = value
if (
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
isHydrating &&
!attributeHasMismatch(el, 'value', getClientText(el, value))
) {
return
}
// #4956: <option> value will fallback to its text content so we need to
// compare against its attribute value instead.
const oldValue = el.tagName === 'OPTION' ? el.getAttribute('value') : el.value
@ -179,28 +256,81 @@ export function setValue(el: TargetElement, value: any): void {
* `toDisplayString`
*/
export function setText(el: Text & { $txt?: string }, value: string): void {
if (isHydrating) {
const clientText = getClientText(el.parentNode!, value)
if (el.nodeValue == clientText) {
el.$txt = clientText
return
}
;(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
warn(
`Hydration text mismatch in`,
el.parentNode,
`\n - rendered on server: ${JSON.stringify((el as Text).data)}` +
`\n - expected on client: ${JSON.stringify(value)}`,
)
logMismatchError()
}
if (el.$txt !== value) {
el.nodeValue = el.$txt = value
}
}
/**
* Used by
* - setDynamicProps, need to guard with `toDisplayString`
* - v-text on dynamic component, value passed here is already converted
* Used by setDynamicProps only, so need to guard with `toDisplayString`
*/
export function setElementText(
el: Node & { $txt?: string },
value: unknown,
isConverted: boolean = false,
): void {
if (el.$txt !== (value = isConverted ? value : toDisplayString(value))) {
value = toDisplayString(value)
if (isHydrating) {
let clientText = getClientText(el, value as string)
if (el.textContent === clientText) {
el.$txt = clientText
return
}
if (!isMismatchAllowed(el as Element, 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: ${clientText}`,
)
logMismatchError()
}
}
if (el.$txt !== value) {
el.textContent = el.$txt = value as string
}
}
export function setHtml(el: TargetElement, value: any): void {
value = value == null ? '' : value
if (isHydrating) {
if (el.innerHTML === value) {
el.$html = value
return
}
if (!isMismatchAllowed(el, MismatchTypes.CHILDREN)) {
if (__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
warn(
`Hydration children mismatch on`,
el,
`\nServer rendered element contains different child nodes from client nodes.`,
)
}
logMismatchError()
}
}
if (el.$html !== value) {
el.innerHTML = el.$html = value
}
@ -285,3 +415,83 @@ export function optimizePropertyLookup(): void {
(Text.prototype as any).$txt =
''
}
function classHasMismatch(
el: TargetElement | any,
expected: string,
isIncremental: boolean,
): boolean {
const actual = el.getAttribute('class')
const actualClassSet = toClassSet(actual || '')
const expectedClassSet = toClassSet(expected)
const hasMismatch = isIncremental
? // check if the expected classes are present in the actual classes
Array.from(expectedClassSet).some(cls => !actualClassSet.has(cls))
: !isSetEqual(actualClassSet, expectedClassSet)
if (hasMismatch) {
warnPropMismatch(el, 'class', MismatchTypes.CLASS, actual, expected)
logMismatchError()
return true
}
return false
}
function styleHasMismatch(
el: TargetElement | any,
value: any,
normalizedValue: string | NormalizedStyle | undefined,
isIncremental: boolean,
): boolean {
const actual = el.getAttribute('style')
const actualStyleMap = toStyleMap(actual || '')
const expected = isString(value) ? value : stringifyStyle(normalizedValue)
const expectedStyleMap = toStyleMap(expected)
// If `v-show=false`, `display: 'none'` should be added to expected
if (el[vShowHidden]) {
expectedStyleMap.set('display', 'none')
}
// TODO: handle css vars
const hasMismatch = isIncremental
? // check if the expected styles are present in the actual styles
Array.from(expectedStyleMap.entries()).some(
([key, val]) => actualStyleMap.get(key) !== val,
)
: !isMapEqual(actualStyleMap, expectedStyleMap)
if (hasMismatch) {
warnPropMismatch(el, 'style', MismatchTypes.STYLE, actual, expected)
logMismatchError()
return true
}
return false
}
function attributeHasMismatch(el: any, key: string, value: any): boolean {
if (isValidHtmlOrSvgAttribute(el, key)) {
const { actual, expected } = getAttributeMismatch(el, key, value)
if (actual !== expected) {
warnPropMismatch(el, key, MismatchTypes.ATTRIBUTE, actual, expected)
logMismatchError()
return true
}
}
return false
}
function getClientText(el: Node, value: string): string {
if (
value[0] === '\n' &&
((el as Element).tagName === 'PRE' ||
(el as Element).tagName === 'TEXTAREA')
) {
value = value.slice(1)
}
return value
}

View File

@ -19,10 +19,7 @@ export function template(
const fn = () => {
if (isHydrating) {
currentTemplateFn = fn
if (__DEV__ && !currentHydrationNode) {
// TODO this should not happen
throw new Error('No current hydration node')
}
// do not cache the adopted node in node because it contains child nodes
// this avoids duplicate rendering of children
const adopted = adoptTemplate(currentHydrationNode!, html)!