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
|
||||
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 {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 ` +
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)!
|
||||
|
|
Loading…
Reference in New Issue