fix(components): [tooltip] SSR hydration error caused by random ID (#10541)

This commit is contained in:
qiang 2022-11-21 14:10:52 +08:00 committed by GitHub
parent d8a116c37f
commit b456125431
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 206 additions and 77 deletions

View File

@ -2,7 +2,7 @@ import { nextTick, reactive } from 'vue'
import { mount } from '@vue/test-utils'
import { NOOP } from '@vue/shared'
import { beforeEach, describe, expect, it, test, vi } from 'vitest'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import { ElFormItem as FormItem } from '@element-plus/components/form'
import Autocomplete from '../src/autocomplete.vue'
@ -321,9 +321,10 @@ describe('Autocomplete.vue', () => {
_mount()
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)?.innerHTML).not.toBe(
''
)
})
it('should not mount on the popper container', async () => {
@ -333,9 +334,8 @@ describe('Autocomplete.vue', () => {
})
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)?.innerHTML).toBe('')
})
})

View File

@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, test, vi } from 'vitest'
import { EVENT_CODE } from '@element-plus/constants'
import triggerEvent from '@element-plus/test-utils/trigger-event'
import { ArrowDown, Check, CircleClose } from '@element-plus/icons-vue'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import { hasClass } from '@element-plus/utils'
import ElForm, { ElFormItem } from '@element-plus/components/form'
import Cascader from '../src/index.vue'
@ -376,9 +376,10 @@ describe('Cascader.vue', () => {
))
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)!.innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)!.innerHTML).not.toBe(
''
)
})
it('should not mount on the popper container', async () => {
@ -394,9 +395,8 @@ describe('Cascader.vue', () => {
))
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)!.innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)!.innerHTML).toBe('')
})
})

View File

@ -6,7 +6,7 @@ import { rAF } from '@element-plus/test-utils/tick'
import { EVENT_CODE } from '@element-plus/constants'
import { ElTooltip } from '@element-plus/components/tooltip'
import Button from '@element-plus/components/button'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import Dropdown from '../src/dropdown.vue'
import DropdownItem from '../src/dropdown-item.vue'
import DropdownMenu from '../src/dropdown-menu.vue'
@ -785,9 +785,8 @@ describe('Dropdown', () => {
)
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value).innerHTML).not.toBe('')
})
test('should not mount on the popper container', async () => {
@ -812,9 +811,8 @@ describe('Dropdown', () => {
)
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value).innerHTML).toBe('')
})
})
})

View File

@ -2,7 +2,7 @@ import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { rAF } from '@element-plus/test-utils/tick'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import Popconfirm from '../src/popconfirm.vue'
const AXIOM = 'rem is the best girl'
@ -55,9 +55,10 @@ describe('Popconfirm.vue', () => {
))
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)!.innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)!.innerHTML).not.toBe(
''
)
})
it('should not mount on the popper container', async () => {
@ -75,9 +76,8 @@ describe('Popconfirm.vue', () => {
))
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)!.innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)!.innerHTML).toBe('')
})
})
})

View File

@ -1,7 +1,7 @@
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { POPPER_CONTAINER_SELECTOR, useZIndex } from '@element-plus/hooks'
import { usePopperContainerId, useZIndex } from '@element-plus/hooks'
import { rAF } from '@element-plus/test-utils/tick'
import { ElPopperTrigger } from '@element-plus/components/popper'
import Popover from '../src/popover.vue'
@ -199,9 +199,10 @@ describe('Popover.vue', () => {
_mount()
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)?.innerHTML).not.toBe(
''
)
})
it('should not mount on the popper container', async () => {
@ -209,9 +210,8 @@ describe('Popover.vue', () => {
_mount({ teleported: false })
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)?.innerHTML).toBe('')
})
})
})

View File

@ -7,7 +7,7 @@ import { EVENT_CODE } from '@element-plus/constants'
import { makeMountFunc } from '@element-plus/test-utils/make-mount'
import { rAF } from '@element-plus/test-utils/tick'
import { CircleClose } from '@element-plus/icons-vue'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import Select from '../src/select.vue'
vi.mock('lodash-unified', async () => {
@ -1502,9 +1502,10 @@ describe('Select', () => {
})
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR)!.innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)!.innerHTML).not.toBe(
''
)
})
it('should not mount on the popper container', async () => {
@ -1542,9 +1543,8 @@ describe('Select', () => {
})
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value).innerHTML).toBe('')
})
})

View File

@ -4,7 +4,7 @@ import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, test, vi } from 'vitest'
import { EVENT_CODE } from '@element-plus/constants'
import { ArrowDown, CaretTop, CircleClose } from '@element-plus/icons-vue'
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
import { usePopperContainerId } from '@element-plus/hooks'
import { hasClass } from '@element-plus/utils'
import { ElFormItem } from '@element-plus/components/form'
import Select from '../src/select.vue'
@ -1910,9 +1910,8 @@ describe('Select', () => {
)
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
).not.toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value).innerHTML).not.toBe('')
})
it('should not mount on the popper container', async () => {
@ -1940,9 +1939,8 @@ describe('Select', () => {
)
await nextTick()
expect(
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
).toBe('')
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value).innerHTML).toBe('')
})
})

View File

@ -1,10 +1,6 @@
import { buildProps, definePropType } from '@element-plus/utils'
import { popperContentProps } from '@element-plus/components/popper'
import {
POPPER_CONTAINER_SELECTOR,
useDelayedToggleProps,
useNamespace,
} from '@element-plus/hooks'
import { useDelayedToggleProps, useNamespace } from '@element-plus/hooks'
import type { ExtractPropTypes } from 'vue'
const ns = useNamespace('tooltip')
@ -14,7 +10,6 @@ export const useTooltipContentProps = buildProps({
...popperContentProps,
appendTo: {
type: definePropType<string | HTMLElement>([String, Object]),
default: POPPER_CONTAINER_SELECTOR,
},
content: {
type: String,

View File

@ -36,7 +36,6 @@
@blur="onBlur"
@close="onClose"
>
<!-- Workaround bug #6378 -->
<template v-if="!destroyed">
<slot />
</template>
@ -48,6 +47,7 @@
<script lang="ts" setup>
import { computed, inject, onBeforeUnmount, ref, unref, watch } from 'vue'
import { onClickOutside } from '@vueuse/core'
import { usePopperContainerId } from '@element-plus/hooks'
import { composeEventHandlers } from '@element-plus/utils'
import { ElPopperContent } from '@element-plus/components/popper'
import { TOOLTIP_INJECTION_KEY } from '@element-plus/tokens'
@ -59,6 +59,8 @@ defineOptions({
})
const props = defineProps(useTooltipContentProps)
const { selector } = usePopperContainerId()
// TODO any is temporary, replace with `InstanceType<typeof ElPopperContent> | null` later
const contentRef = ref<any>(null)
const destroyed = ref(false)
@ -95,6 +97,10 @@ const shouldShow = computed(() => {
return props.disabled ? false : unref(open)
})
const appendTo = computed(() => {
return props.appendTo || selector.value
})
const contentStyle = computed(() => (props.style ?? {}) as any)
const ariaHidden = computed(() => !unref(open))

View File

@ -0,0 +1,71 @@
import { config, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ID_INJECTION_KEY, useId, useIdInjection } from '../use-id'
describe('no injection value', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('useIdInjection', () => {
const wrapper = mount({
setup() {
const idInjection = useIdInjection()
return idInjection
},
})
expect(wrapper.vm.prefix).toMatch(/^\d{0,4}$/)
expect(wrapper.vm.current).toBe(0)
})
it('useId', () => {
const wrapper = mount({
setup() {
const id = useId()
return { id }
},
})
expect(wrapper.vm.id).toMatch(/^el-id-\d{0,4}-\d+$/)
})
})
describe('with injection value', () => {
beforeEach(() => {
config.global.provide = {
[ID_INJECTION_KEY as symbol]: {
prefix: 1024,
current: 0,
},
}
})
afterEach(() => {
document.body.innerHTML = ''
config.global.provide = {}
})
it('useIdInjection', () => {
const wrapper = mount({
setup() {
const idInjection = useIdInjection()
return idInjection
},
})
expect(wrapper.vm.prefix).toBe(1024)
expect(wrapper.vm.current).toBe(0)
})
it('useId', () => {
const wrapper = mount({
setup() {
const id = useId()
return { id }
},
})
expect(wrapper.vm.id).toBe('el-id-1024-0')
})
})

View File

@ -1,11 +1,12 @@
import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { config, mount, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as vueuse from '@vueuse/core'
import {
POPPER_CONTAINER_SELECTOR,
usePopperContainer,
usePopperContainerId,
} from '../use-popper-container'
import { ID_INJECTION_KEY } from '../use-id'
const AXIOM = 'rem is the best girl'
@ -31,14 +32,62 @@ describe('usePopperContainer', () => {
it('should append container to the DOM root', async () => {
mountComponent()
await nextTick()
expect(document.body.querySelector(POPPER_CONTAINER_SELECTOR)).toBeDefined()
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)).toBeDefined()
})
it('should not append container to the DOM root', async () => {
;(vueuse as any).isClient = false
mountComponent()
await nextTick()
expect(document.body.querySelector(POPPER_CONTAINER_SELECTOR)).toBeNull()
const { selector } = usePopperContainerId()
expect(document.body.querySelector(selector.value)).toBeNull()
})
})
describe('no injection value', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('usePopperContainerId', () => {
const wrapper = mount({
setup() {
const data = usePopperContainerId()
return data
},
})
expect(wrapper.vm.id).toMatch(/^el-popper-container-\d{0,4}$/)
expect(wrapper.vm.selector).toMatch(/^#el-popper-container-\d{0,4}$/)
expect(wrapper.vm.selector).toBe(`#${wrapper.vm.id}`)
})
})
describe('with injection value', () => {
beforeEach(() => {
config.global.provide = {
[ID_INJECTION_KEY as symbol]: {
prefix: 1024,
current: 0,
},
}
})
afterEach(() => {
document.body.innerHTML = ''
config.global.provide = {}
})
it('usePopperContainerId', () => {
const wrapper = mount({
setup() {
const data = usePopperContainerId()
return data
},
})
expect(wrapper.vm.id).toBe('el-popper-container-1024')
expect(wrapper.vm.selector).toBe('#el-popper-container-1024')
})
})

View File

@ -1,4 +1,4 @@
import { computed, inject, unref } from 'vue'
import { computed, getCurrentInstance, inject, unref } from 'vue'
import { isClient } from '@vueuse/core'
import { debugWarn } from '@element-plus/utils'
import { useGlobalConfig } from '../use-global-config'
@ -20,9 +20,14 @@ const defaultIdInjection = {
export const ID_INJECTION_KEY: InjectionKey<ElIdInjectionContext> =
Symbol('elIdInjection')
export const useId = (deterministicId?: MaybeRef<string>): Ref<string> => {
const idInjection = inject(ID_INJECTION_KEY, defaultIdInjection)
export const useIdInjection = (): ElIdInjectionContext => {
return getCurrentInstance()
? inject(ID_INJECTION_KEY, defaultIdInjection)
: defaultIdInjection
}
export const useId = (deterministicId?: MaybeRef<string>): Ref<string> => {
const idInjection = useIdInjection()
if (!isClient && idInjection === defaultIdInjection) {
debugWarn(
'IdInjection',

View File

@ -1,22 +1,29 @@
import { onBeforeMount } from 'vue'
import { computed, onBeforeMount } from 'vue'
import { isClient } from '@vueuse/core'
import { generateId } from '@element-plus/utils'
import { useGlobalConfig } from '../use-global-config'
import { defaultNamespace } from '../use-namespace'
import { useIdInjection } from '../use-id'
let cachedContainer: HTMLElement
const namespace = useGlobalConfig('namespace', defaultNamespace)
export const usePopperContainerId = () => {
const namespace = useGlobalConfig('namespace', defaultNamespace)
const idInjection = useIdInjection()
export const POPPER_CONTAINER_ID = `${
namespace.value
}-popper-container-${generateId()}`
const id = computed(() => {
return `${namespace.value}-popper-container-${idInjection.prefix}`
})
const selector = computed(() => `#${id.value}`)
export const POPPER_CONTAINER_SELECTOR = `#${POPPER_CONTAINER_ID}`
return {
id,
selector,
}
}
const createContainer = () => {
const createContainer = (id: string) => {
const container = document.createElement('div')
container.id = POPPER_CONTAINER_ID
container.id = id
document.body.appendChild(container)
return container
}
@ -25,15 +32,15 @@ export const usePopperContainer = () => {
onBeforeMount(() => {
if (!isClient) return
const { id, selector } = usePopperContainerId()
// This is for bypassing the error that when under testing env, we often encounter
// document.body.innerHTML = '' situation
// for this we need to disable the caching since it's not really needed
if (
process.env.NODE_ENV === 'test' ||
!cachedContainer ||
!document.body.querySelector(POPPER_CONTAINER_SELECTOR)
(!cachedContainer && !document.body.querySelector(selector.value))
) {
cachedContainer = createContainer()
cachedContainer = createContainer(id.value)
}
})
}