headlessui
headlessui copied to clipboard
Vue Test Utils' teleport stub and headlessui's Dialog don't work well together
What package within Headless UI are you using?
@headlessui/vue
What version of that package are you using?
^1.4.2
What browser are you using?
N/A
Reproduction URL
https://github.com/dospunk/headlessui-vue-dialog-testing-error
Describe your issue
HeadlessUI's Dialog is difficult to test because if its use of teleport. Recently Vue Test Utils added the ability to stub out teleport automatically, so that the contents of the teleport can be tested more easily. Unfortunately, this causes some sort of recursive warning with the Dialog's FocusTrap.
In the included repo there is a test file tests/components/HelloWorld.test.js containing two tests. The first doesn't stub teleport, and as a result fails because it cannot get the contents of the Dialog. The second does stub teleport, and so it generates a ton of warnings about FocusTrap not having any focusable elements (even though the Dialog contains a button), and then warnings about a recursive error. Ultimately the second test passes, but with a larger component it would simply time out.
You can see that no errors occur when the page actually renders in the browser by running npm run dev and navigating to the url displayed.
Are there any news on this? I did ran into the same issue and wondered if someone found a solution and wanted to share it 😄
@senaria I just ended up having to make some stubs:
DialogStub.vue
<template>
<component :is="as" v-if="mountState" v-show="showState">
<slot :open="open" />
</component>
</template>
<script lang="ts">
import { computed } from '@vue/reactivity'
import { DefineComponent, defineComponent, PropType } from 'vue'
/** Note: focus trap functionality not implemented */
export default defineComponent({
props: {
open: {
type: Boolean,
default: undefined,
},
/** Functionality not implemented */
initialFocus: {
type: HTMLElement,
default: null,
},
as: {
type: [String, Object] as PropType<string | DefineComponent>,
default: 'div',
},
static: {
type: Boolean,
default: false,
},
unmount: {
type: Boolean,
default: true,
},
},
setup(props) {
const mountState = computed(() => {
if (props.static || !props.unmount) return true
else return props.open
})
const showState = computed(() => {
if (props.static || props.unmount) return true
else return props.open
})
return { mountState, showState }
},
})
</script>
DialogTitleStub.vue
<template>
<component :is="as"><slot /></component>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
as: { type: [Object, String], default: 'h2' },
},
setup() {
// access to open through slot not implemented
},
})
</script>
DialogOverlayStub.vue
<template>
<component :is="as"><slot /></component>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
props: {
as: { type: [Object, String], default: 'div' },
},
setup() {
// close event not implemented. If this becomes necessary, see
//https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-vue/src/components/dialog/dialog.ts#L172
// access to open through slot not implemented
},
})
</script>
@dospunk thanks man. I am appreciating it. Apparently they moved a bit and overlay is now deprecated and if you want to use the panel you would also stub it. But definitely leaded me into the right direction. 👍
Hey! Thank you for your bug report! Much appreciated! 🙏
Looks like you found a way around the problem and seems that your issue is fixed now.
HeadlessUI's Dialog is difficult to test because if its use of teleport.
That's sadly a reality that exists and we don't have any plans to remove the teleport functionality from the Dialog.
The second does stub teleport, and so it generates a ton of warnings about FocusTrap not having any focusable elements (even though the Dialog contains a button), and then warnings about a recursive error.
The good news here is that in the latest version you won't get spammend with a wall of warnings. Sadly the last warning (maximum call stack) still exists when you stub this teleport functionality. The confusing part is that this does not happen at all in production so I wonder if there is anything we can do about that in Headless UI itself. I'm also not sure what the built-in teleport stubbing is doing exactly which could result in incorrect behaviour.
Because you've found a workaround with manual stubs that solve your issue, I'm going to close this issue. If you or anyone else run into other issues then feel free to open a new issue with a reproduction repo attached. 👍
Makes sense, thanks for the response!
@senaria I just ended up having to make some stubs:
DialogStub.vue
<template> <component :is="as" v-if="mountState" v-show="showState"> <slot :open="open" /> </component> </template> <script lang="ts"> import { computed } from '@vue/reactivity' import { DefineComponent, defineComponent, PropType } from 'vue' /** Note: focus trap functionality not implemented */ export default defineComponent({ props: { open: { type: Boolean, default: undefined, }, /** Functionality not implemented */ initialFocus: { type: HTMLElement, default: null, }, as: { type: [String, Object] as PropType<string | DefineComponent>, default: 'div', }, static: { type: Boolean, default: false, }, unmount: { type: Boolean, default: true, }, }, setup(props) { const mountState = computed(() => { if (props.static || !props.unmount) return true else return props.open }) const showState = computed(() => { if (props.static || props.unmount) return true else return props.open }) return { mountState, showState } }, }) </script>DialogTitleStub.vue
<template> <component :is="as"><slot /></component> </template> <script lang="ts"> import { defineComponent } from 'vue' export default defineComponent({ props: { as: { type: [Object, String], default: 'h2' }, }, setup() { // access to open through slot not implemented }, }) </script>DialogOverlayStub.vue
<template> <component :is="as"><slot /></component> </template> <script lang="ts"> import { defineComponent } from 'vue' export default defineComponent({ props: { as: { type: [Object, String], default: 'div' }, }, setup() { // close event not implemented. If this becomes necessary, see //https://github.com/tailwindlabs/headlessui/blob/main/packages/%40headlessui-vue/src/components/dialog/dialog.ts#L172 // access to open through slot not implemented }, }) </script>
Hey @dospunk thanks for the answer, but how do I use it in .test.ts file?
Is this the correct way?:
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import BaseModal from './BaseModal.vue'
import Dialogstub from './components/DialogStub.vue'
describe('BaseModal Component', () => {
it('imports as expected', () => {
const wrapper = mount(BaseModal, {
global: {
stubs: {
TransitionRoot: true,
TransitionChild: true,
Dialog: mount(Dialogstub),
DialogPanel: true
}
}
})
expect(wrapper).toBeDefined()
})
})
@Sardor01 You don't have to mount the stub, but other than that yes that looks right to me
Same problem here, really annoying. Official documentation does not help either (https://test-utils.vuejs.org/guide/advanced/teleport.html)
Edit: This one works for me.
const wrapper = mount(MyComp, {
global: {
plugins: [createTestingPinia()],
stubs: {
TransitionRoot: true,
TransitionChild: true,
Dialog: DialogstubVue,
DialogPanel: {
template: '<div><slot /></div>',
},
DialogTitle: true,
},
},
});