Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: avoid hydration mismatch warning for styles with different order #10011

Merged
merged 9 commits into from
Jan 8, 2024
32 changes: 31 additions & 1 deletion packages/runtime-core/__tests__/hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1431,11 +1431,35 @@ describe('SSR hydration', () => {
mountWithHydration(`<div style="color:red;"></div>`, () =>
h('div', { style: `color:red;` }),
)
mountWithHydration(
`<div style="color:red; font-size: 12px;"></div>`,
() => h('div', { style: `font-size: 12px; color:red;` }),
)
mountWithHydration(`<div style="color:red;display:none;"></div>`, () =>
withDirectives(createVNode('div', { style: 'color: red' }, ''), [
[vShow, false],
]),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
mountWithHydration(`<div style="color:red;"></div>`, () =>
h('div', { style: { color: 'green' } }),
)
expect(`Hydration style mismatch`).toHaveBeenWarned()
expect(`Hydration style mismatch`).toHaveBeenWarnedTimes(1)
})

test('style mismatch w/ v-show', () => {
mountWithHydration(`<div style="color:red;display:none"></div>`, () =>
withDirectives(createVNode('div', { style: 'color: red' }, ''), [
[vShow, false],
]),
)
expect(`Hydration style mismatch`).not.toHaveBeenWarned()
mountWithHydration(`<div style="color:red;"></div>`, () =>
withDirectives(createVNode('div', { style: 'color: red' }, ''), [
[vShow, false],
]),
)
expect(`Hydration style mismatch`).toHaveBeenWarnedTimes(1)
})

test('attr mismatch', () => {
Expand All @@ -1451,6 +1475,12 @@ describe('SSR hydration', () => {
mountWithHydration(`<select multiple></div>`, () =>
h('select', { multiple: 'multiple' }),
)
mountWithHydration(`<textarea>foo</textarea>`, () =>
h('textarea', { value: 'foo' }),
)
mountWithHydration(`<textarea></textarea>`, () =>
h('textarea', { value: '' }),
)
expect(`Hydration attribute mismatch`).not.toHaveBeenWarned()

mountWithHydration(`<div></div>`, () => h('div', { id: 'foo' }))
Expand Down
65 changes: 56 additions & 9 deletions packages/runtime-core/src/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ export function createHydrationFunctions(
) {
for (const key in props) {
// check hydration mismatch
if (__DEV__ && propHasMismatch(el, key, props[key])) {
if (__DEV__ && propHasMismatch(el, key, props[key], vnode)) {
hasMismatch = true
}
if (
Expand Down Expand Up @@ -712,7 +712,12 @@ export function createHydrationFunctions(
/**
* Dev only
*/
function propHasMismatch(el: Element, key: string, clientValue: any): boolean {
function propHasMismatch(
el: Element,
key: string,
clientValue: any,
vnode: VNode,
): boolean {
let mismatchType: string | undefined
let mismatchKey: string | undefined
let actual: any
Expand All @@ -726,24 +731,41 @@ function propHasMismatch(el: Element, key: string, clientValue: any): boolean {
mismatchType = mismatchKey = `class`
}
} else if (key === 'style') {
actual = el.getAttribute('style')
expected = isString(clientValue)
? clientValue
: stringifyStyle(normalizeStyle(clientValue))
if (actual !== expected) {
// style might be in different order, but that doesn't affect cascade
actual = toStyleMap(el.getAttribute('style') || '')
expected = toStyleMap(
isString(clientValue)
? clientValue
: stringifyStyle(normalizeStyle(clientValue)),
)
// If `v-show=false`, `display: 'none'` should be added to expected
if (vnode.dirs) {
for (const { dir, value } of vnode.dirs) {
// @ts-expect-error only vShow has this internal name
if (dir.name === 'show' && !value) {
expected.set('display', 'none')
}
}
}
if (!isMapEqual(actual, expected)) {
mismatchType = mismatchKey = 'style'
}
} else if (
(el instanceof SVGElement && isKnownSvgAttr(key)) ||
(el instanceof HTMLElement && (isBooleanAttr(key) || isKnownHtmlAttr(key)))
) {
actual = el.hasAttribute(key) && el.getAttribute(key)
// #10000 some attrs such as textarea.value can't be get by `hasAttribute`
actual = el.hasAttribute(key)
? el.getAttribute(key)
: key in el
? el[key as keyof typeof el]
: ''
expected = isBooleanAttr(key)
? includeBooleanAttr(clientValue)
? ''
: false
: clientValue == null
? false
? ''
: String(clientValue)
if (actual !== expected) {
mismatchType = `attribute`
Expand Down Expand Up @@ -783,3 +805,28 @@ function isSetEqual(a: Set<string>, b: Set<string>): boolean {
}
return true
}

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(':')
key = key?.trim()
value = value?.trim()
if (key && value) {
styleMap.set(key, value)
}
}
return styleMap
}

function isMapEqual(a: Map<string, string>, b: Map<string, string>): boolean {
if (a.size !== b.size) {
return false
}
for (const [key, value] of a) {
if (value !== b.get(key)) {
return false
}
}
return true
}
6 changes: 5 additions & 1 deletion packages/runtime-dom/src/directives/vShow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface VShowElement extends HTMLElement {
[vShowOldKey]: string
}

export const vShow: ObjectDirective<VShowElement> = {
export const vShow: ObjectDirective<VShowElement> & { name?: 'show' } = {
beforeMount(el, { value }, { transition }) {
el[vShowOldKey] = el.style.display === 'none' ? '' : el.style.display
if (transition && value) {
Expand Down Expand Up @@ -42,6 +42,10 @@ export const vShow: ObjectDirective<VShowElement> = {
},
}

if (__DEV__) {
vShow.name = 'show'
}

function setDisplay(el: VShowElement, value: unknown): void {
el.style.display = value ? el[vShowOldKey] : 'none'
}
Expand Down