mirror of https://github.com/vuejs/core.git
chore: hydration mismatch handling
This commit is contained in:
parent
c272550f85
commit
c42499c3f0
|
@ -869,35 +869,61 @@ function propHasMismatch(
|
||||||
mismatchType = MismatchTypes.STYLE
|
mismatchType = MismatchTypes.STYLE
|
||||||
mismatchKey = 'style'
|
mismatchKey = 'style'
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (isValidHtmlOrSvgAttribute(el, key)) {
|
||||||
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
|
;({ actual, expected } = getAttributeMismatch(el, key, clientValue))
|
||||||
(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
|
|
||||||
}
|
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
mismatchType = MismatchTypes.ATTRIBUTE
|
mismatchType = MismatchTypes.ATTRIBUTE
|
||||||
mismatchKey = key
|
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)) {
|
if (mismatchType != null && !isMismatchAllowed(el, mismatchType)) {
|
||||||
const format = (v: any) =>
|
const format = (v: any) =>
|
||||||
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
|
v === false ? `(not rendered)` : `${mismatchKey}="${v}"`
|
||||||
|
@ -920,11 +946,11 @@ function propHasMismatch(
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function toClassSet(str: string): Set<string> {
|
export function toClassSet(str: string): Set<string> {
|
||||||
return new Set(str.trim().split(/\s+/))
|
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) {
|
if (a.size !== b.size) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -936,7 +962,7 @@ function isSetEqual(a: Set<string>, b: Set<string>): boolean {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStyleMap(str: string): Map<string, string> {
|
export function toStyleMap(str: string): Map<string, string> {
|
||||||
const styleMap: Map<string, string> = new Map()
|
const styleMap: Map<string, string> = new Map()
|
||||||
for (const item of str.split(';')) {
|
for (const item of str.split(';')) {
|
||||||
let [key, value] = item.split(':')
|
let [key, value] = item.split(':')
|
||||||
|
@ -949,7 +975,10 @@ function toStyleMap(str: string): Map<string, string> {
|
||||||
return styleMap
|
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) {
|
if (a.size !== b.size) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -991,7 +1020,7 @@ function resolveCssVars(
|
||||||
|
|
||||||
const allowMismatchAttr = 'data-allow-mismatch'
|
const allowMismatchAttr = 'data-allow-mismatch'
|
||||||
|
|
||||||
enum MismatchTypes {
|
export enum MismatchTypes {
|
||||||
TEXT = 0,
|
TEXT = 0,
|
||||||
CHILDREN = 1,
|
CHILDREN = 1,
|
||||||
CLASS = 2,
|
CLASS = 2,
|
||||||
|
@ -1007,7 +1036,7 @@ const MismatchTypeString: Record<MismatchTypes, string> = {
|
||||||
[MismatchTypes.ATTRIBUTE]: 'attribute',
|
[MismatchTypes.ATTRIBUTE]: 'attribute',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
function isMismatchAllowed(
|
export function isMismatchAllowed(
|
||||||
el: Element | null,
|
el: Element | null,
|
||||||
allowedType: MismatchTypes,
|
allowedType: MismatchTypes,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
|
@ -597,6 +597,20 @@ export { markAsyncBoundary } from './helpers/useId'
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export { createInternalObject } from './internalObject'
|
export { createInternalObject } from './internalObject'
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
MismatchTypes,
|
||||||
|
isMismatchAllowed,
|
||||||
|
toClassSet,
|
||||||
|
isSetEqual,
|
||||||
|
warnPropMismatch,
|
||||||
|
toStyleMap,
|
||||||
|
isMapEqual,
|
||||||
|
isValidHtmlOrSvgAttribute,
|
||||||
|
getAttributeMismatch,
|
||||||
|
} from './hydration'
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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(
|
async function testHydration(
|
||||||
code: string,
|
code: string,
|
||||||
components: Record<string, string | { code: string; vapor: boolean }> = {},
|
components: Record<string, string | { code: string; vapor: boolean }> = {},
|
||||||
|
@ -233,7 +253,7 @@ describe('Vapor Mode hydration', () => {
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
|
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
|
||||||
`"<div> </div>"`,
|
`"<div></div>"`,
|
||||||
)
|
)
|
||||||
|
|
||||||
data.txt = 'foo'
|
data.txt = 'foo'
|
||||||
|
@ -255,7 +275,7 @@ describe('Vapor Mode hydration', () => {
|
||||||
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
|
expect(formatHtml(container.innerHTML)).toMatchInlineSnapshot(
|
||||||
`
|
`
|
||||||
"
|
"
|
||||||
<!--[--> <!--]-->
|
<!--[--><!--]-->
|
||||||
"
|
"
|
||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
|
@ -2900,15 +2920,649 @@ describe('Vapor Mode hydration', () => {
|
||||||
test.todo('force hydrate custom element with dynamic props', () => {})
|
test.todo('force hydrate custom element with dynamic props', () => {})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe.todo('data-allow-mismatch')
|
|
||||||
|
|
||||||
describe.todo('mismatch handling')
|
|
||||||
|
|
||||||
describe.todo('Teleport')
|
describe.todo('Teleport')
|
||||||
|
|
||||||
describe.todo('Suspense')
|
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', () => {
|
describe('VDOM interop', () => {
|
||||||
test('basic render vapor component', async () => {
|
test('basic render vapor component', async () => {
|
||||||
const data = ref(true)
|
const data = ref(true)
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
import {
|
import {
|
||||||
|
MismatchTypes,
|
||||||
type VShowElement,
|
type VShowElement,
|
||||||
vShowHidden,
|
vShowHidden,
|
||||||
vShowOriginalDisplay,
|
vShowOriginalDisplay,
|
||||||
warn,
|
warn,
|
||||||
|
warnPropMismatch,
|
||||||
} from '@vue/runtime-dom'
|
} from '@vue/runtime-dom'
|
||||||
import { renderEffect } from '../renderEffect'
|
import { renderEffect } from '../renderEffect'
|
||||||
import { isVaporComponent } from '../component'
|
import { isVaporComponent } from '../component'
|
||||||
import type { Block, TransitionBlock } from '../block'
|
import type { Block, TransitionBlock } from '../block'
|
||||||
import { isArray } from '@vue/shared'
|
import { isArray } from '@vue/shared'
|
||||||
import { DynamicFragment, VaporFragment } from '../fragment'
|
import { DynamicFragment, VaporFragment } from '../fragment'
|
||||||
|
import { isHydrating, logMismatchError } from '../dom/hydration'
|
||||||
|
|
||||||
export function applyVShow(target: Block, source: () => any): void {
|
export function applyVShow(target: Block, source: () => any): void {
|
||||||
if (isVaporComponent(target)) {
|
if (isVaporComponent(target)) {
|
||||||
|
@ -74,9 +77,29 @@ function setDisplay(target: Block, value: unknown): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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__) {
|
} else if (__DEV__) {
|
||||||
warn(
|
warn(
|
||||||
`v-show used on component with non-single-element root node ` +
|
`v-show used on component with non-single-element root node ` +
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { warn } from '@vue/runtime-dom'
|
import { MismatchTypes, isMismatchAllowed, warn } from '@vue/runtime-dom'
|
||||||
import {
|
import {
|
||||||
type ChildItem,
|
type ChildItem,
|
||||||
incrementIndexOffset,
|
incrementIndexOffset,
|
||||||
|
@ -8,10 +8,15 @@ import {
|
||||||
setInsertionState,
|
setInsertionState,
|
||||||
} from '../insertionState'
|
} from '../insertionState'
|
||||||
import {
|
import {
|
||||||
|
_next,
|
||||||
|
child,
|
||||||
|
createElement,
|
||||||
createTextNode,
|
createTextNode,
|
||||||
disableHydrationNodeLookup,
|
disableHydrationNodeLookup,
|
||||||
enableHydrationNodeLookup,
|
enableHydrationNodeLookup,
|
||||||
|
parentNode,
|
||||||
} from './node'
|
} from './node'
|
||||||
|
import { remove } from '../block'
|
||||||
|
|
||||||
const isHydratingStack = [] as boolean[]
|
const isHydratingStack = [] as boolean[]
|
||||||
export let isHydrating = false
|
export let isHydrating = false
|
||||||
|
@ -113,28 +118,23 @@ function adoptTemplateImpl(node: Node, template: string): Node | null {
|
||||||
isComment(node, ']') &&
|
isComment(node, ']') &&
|
||||||
isComment(node.previousSibling!, '[')
|
isComment(node.previousSibling!, '[')
|
||||||
) {
|
) {
|
||||||
node = node.parentNode!.insertBefore(createTextNode(' '), node)
|
const parent = parentNode(node)!
|
||||||
incrementIndexOffset(node.parentNode!)
|
node = parent.insertBefore(createTextNode(), node)
|
||||||
|
incrementIndexOffset(parent)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (__DEV__) {
|
const type = node.nodeType
|
||||||
const type = node.nodeType
|
if (
|
||||||
if (
|
// comment node
|
||||||
(type === 8 && !template.startsWith('<!')) ||
|
(type === 8 && !template.startsWith('<!')) ||
|
||||||
(type === 1 &&
|
// element node
|
||||||
!template.startsWith(`<` + (node as Element).tagName.toLowerCase())) ||
|
(type === 1 &&
|
||||||
(type === 3 &&
|
!template.startsWith(`<` + (node as Element).tagName.toLowerCase()))
|
||||||
template.trim() &&
|
) {
|
||||||
!template.startsWith((node as Text).data))
|
node = handleMismatch(node, template)
|
||||||
) {
|
|
||||||
// TODO recover and provide more info
|
|
||||||
warn(`adopted: `, node)
|
|
||||||
warn(`template: ${template}`)
|
|
||||||
warn('hydration mismatch!')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
advanceHydrationNode(node)
|
advanceHydrationNode(node)
|
||||||
|
@ -210,8 +210,10 @@ function locateHydrationNodeImpl(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (__DEV__ && !node) {
|
if (__DEV__ && !node) {
|
||||||
// TODO more info
|
throw new Error(
|
||||||
warn('Hydration mismatch in ', insertionParent)
|
`No current hydration node was found.\n` +
|
||||||
|
`this is likely a Vue internal bug.`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
resetInsertionState()
|
resetInsertionState()
|
||||||
|
@ -252,3 +254,64 @@ export function locateFragmentEndAnchor(label: string = ']'): Comment | null {
|
||||||
}
|
}
|
||||||
return 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
|
||||||
|
}
|
||||||
|
|
|
@ -21,6 +21,11 @@ export function querySelector(selectors: string): Element | null {
|
||||||
return document.querySelector(selectors)
|
return document.querySelector(selectors)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*! @__NO_SIDE_EFFECTS__ */
|
||||||
|
export function parentNode(node: Node): ParentNode | null {
|
||||||
|
return node.parentNode
|
||||||
|
}
|
||||||
|
|
||||||
/* @__NO_SIDE_EFFECTS__ */
|
/* @__NO_SIDE_EFFECTS__ */
|
||||||
const _txt: typeof _child = _child
|
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
|
// since SSR doesn't generate whitespace placeholder text nodes, if firstChild
|
||||||
// is null, manually insert a text node as the first child
|
// is null, manually insert a text node as the first child
|
||||||
if (!n) {
|
if (!n) {
|
||||||
node.textContent = ' '
|
return node.appendChild(createTextNode())
|
||||||
return node.firstChild!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return n
|
return n
|
||||||
|
|
|
@ -6,20 +6,32 @@ import {
|
||||||
normalizeClass,
|
normalizeClass,
|
||||||
normalizeStyle,
|
normalizeStyle,
|
||||||
parseStringStyle,
|
parseStringStyle,
|
||||||
|
stringifyStyle,
|
||||||
toDisplayString,
|
toDisplayString,
|
||||||
} from '@vue/shared'
|
} from '@vue/shared'
|
||||||
import { on } from './event'
|
import { on } from './event'
|
||||||
import {
|
import {
|
||||||
|
MismatchTypes,
|
||||||
currentInstance,
|
currentInstance,
|
||||||
|
getAttributeMismatch,
|
||||||
|
isMapEqual,
|
||||||
|
isMismatchAllowed,
|
||||||
|
isSetEqual,
|
||||||
|
isValidHtmlOrSvgAttribute,
|
||||||
mergeProps,
|
mergeProps,
|
||||||
patchStyle,
|
patchStyle,
|
||||||
shouldSetAsProp,
|
shouldSetAsProp,
|
||||||
|
toClassSet,
|
||||||
|
toStyleMap,
|
||||||
|
vShowHidden,
|
||||||
warn,
|
warn,
|
||||||
|
warnPropMismatch,
|
||||||
} from '@vue/runtime-dom'
|
} from '@vue/runtime-dom'
|
||||||
import {
|
import {
|
||||||
type VaporComponentInstance,
|
type VaporComponentInstance,
|
||||||
isApplyingFallthroughProps,
|
isApplyingFallthroughProps,
|
||||||
} from '../component'
|
} from '../component'
|
||||||
|
import { isHydrating, logMismatchError } from './hydration'
|
||||||
|
|
||||||
type TargetElement = Element & {
|
type TargetElement = Element & {
|
||||||
$root?: true
|
$root?: true
|
||||||
|
@ -57,6 +69,15 @@ export function setAttr(el: any, key: string, value: any): void {
|
||||||
;(el as any)._falseValue = value
|
;(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}`]) {
|
if (value !== el[`$${key}`]) {
|
||||||
el[`$${key}`] = value
|
el[`$${key}`] = value
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
|
@ -72,6 +93,14 @@ export function setDOMProp(el: any, key: string, value: any): void {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(__DEV__ || __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) &&
|
||||||
|
isHydrating &&
|
||||||
|
!attributeHasMismatch(el, key, value)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const prev = el[key]
|
const prev = el[key]
|
||||||
if (value === prev) {
|
if (value === prev) {
|
||||||
return
|
return
|
||||||
|
@ -112,15 +141,38 @@ export function setDOMProp(el: any, key: string, value: any): void {
|
||||||
export function setClass(el: TargetElement, value: any): void {
|
export function setClass(el: TargetElement, value: any): void {
|
||||||
if (el.$root) {
|
if (el.$root) {
|
||||||
setClassIncremental(el, value)
|
setClassIncremental(el, value)
|
||||||
} else if ((value = normalizeClass(value)) !== el.$cls) {
|
} else {
|
||||||
el.className = el.$cls = value
|
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 {
|
function setClassIncremental(el: any, value: any): void {
|
||||||
const cacheKey = `$clsi${isApplyingFallthroughProps ? '$' : ''}`
|
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]
|
const prev = el[cacheKey]
|
||||||
if ((value = el[cacheKey] = normalizeClass(value)) !== prev) {
|
if ((value = el[cacheKey] = normalizedValue) !== prev) {
|
||||||
const nextList = value.split(/\s+/)
|
const nextList = value.split(/\s+/)
|
||||||
if (value) {
|
if (value) {
|
||||||
el.classList.add(...nextList)
|
el.classList.add(...nextList)
|
||||||
|
@ -137,20 +189,36 @@ export function setStyle(el: TargetElement, value: any): void {
|
||||||
if (el.$root) {
|
if (el.$root) {
|
||||||
setStyleIncremental(el, value)
|
setStyleIncremental(el, value)
|
||||||
} else {
|
} else {
|
||||||
const prev = el.$sty
|
const normalizedValue = normalizeStyle(value)
|
||||||
value = el.$sty = normalizeStyle(value)
|
if (
|
||||||
patchStyle(el, prev, value)
|
(__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 {
|
function setStyleIncremental(el: any, value: any): NormalizedStyle | undefined {
|
||||||
const cacheKey = `$styi${isApplyingFallthroughProps ? '$' : ''}`
|
const cacheKey = `$styi${isApplyingFallthroughProps ? '$' : ''}`
|
||||||
const prev = el[cacheKey]
|
const normalizedValue = isString(value)
|
||||||
value = el[cacheKey] = isString(value)
|
|
||||||
? parseStringStyle(value)
|
? parseStringStyle(value)
|
||||||
: (normalizeStyle(value) as NormalizedStyle | undefined)
|
: (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 {
|
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
|
// store value as _value as well since
|
||||||
// non-string values will be stringified.
|
// non-string values will be stringified.
|
||||||
el._value = value
|
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
|
// #4956: <option> value will fallback to its text content so we need to
|
||||||
// compare against its attribute value instead.
|
// compare against its attribute value instead.
|
||||||
const oldValue = el.tagName === 'OPTION' ? el.getAttribute('value') : el.value
|
const oldValue = el.tagName === 'OPTION' ? el.getAttribute('value') : el.value
|
||||||
|
@ -179,28 +256,81 @@ export function setValue(el: TargetElement, value: any): void {
|
||||||
* `toDisplayString`
|
* `toDisplayString`
|
||||||
*/
|
*/
|
||||||
export function setText(el: Text & { $txt?: string }, value: string): void {
|
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) {
|
if (el.$txt !== value) {
|
||||||
el.nodeValue = el.$txt = value
|
el.nodeValue = el.$txt = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used by
|
* Used by setDynamicProps only, so need to guard with `toDisplayString`
|
||||||
* - setDynamicProps, need to guard with `toDisplayString`
|
|
||||||
* - v-text on dynamic component, value passed here is already converted
|
|
||||||
*/
|
*/
|
||||||
export function setElementText(
|
export function setElementText(
|
||||||
el: Node & { $txt?: string },
|
el: Node & { $txt?: string },
|
||||||
value: unknown,
|
value: unknown,
|
||||||
isConverted: boolean = false,
|
|
||||||
): void {
|
): 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
|
el.textContent = el.$txt = value as string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setHtml(el: TargetElement, value: any): void {
|
export function setHtml(el: TargetElement, value: any): void {
|
||||||
value = value == null ? '' : value
|
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) {
|
if (el.$html !== value) {
|
||||||
el.innerHTML = el.$html = value
|
el.innerHTML = el.$html = value
|
||||||
}
|
}
|
||||||
|
@ -285,3 +415,83 @@ export function optimizePropertyLookup(): void {
|
||||||
(Text.prototype as any).$txt =
|
(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
|
||||||
|
}
|
||||||
|
|
|
@ -19,10 +19,7 @@ export function template(
|
||||||
const fn = () => {
|
const fn = () => {
|
||||||
if (isHydrating) {
|
if (isHydrating) {
|
||||||
currentTemplateFn = fn
|
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
|
// do not cache the adopted node in node because it contains child nodes
|
||||||
// this avoids duplicate rendering of children
|
// this avoids duplicate rendering of children
|
||||||
const adopted = adoptTemplate(currentHydrationNode!, html)!
|
const adopted = adoptTemplate(currentHydrationNode!, html)!
|
||||||
|
|
Loading…
Reference in New Issue