headlessui icon indicating copy to clipboard operation
headlessui copied to clipboard

Vue Test Utils' teleport stub and headlessui's Dialog don't work well together

Open dospunk opened this issue 3 years ago • 3 comments

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.

dospunk avatar Jan 07 '22 20:01 dospunk

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 avatar Jun 10 '22 11:06 senaria

@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 avatar Jun 10 '22 14:06 dospunk

@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. 👍

senaria avatar Jun 13 '22 09:06 senaria

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. 👍

RobinMalfait avatar Aug 30 '22 10:08 RobinMalfait

Makes sense, thanks for the response!

dospunk avatar Sep 02 '22 14:09 dospunk

@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 avatar Dec 24 '22 09:12 sardor01

@Sardor01 You don't have to mount the stub, but other than that yes that looks right to me

dospunk avatar Jan 11 '23 17:01 dospunk

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,
        },
      },
    });

dikaso avatar Jan 29 '24 07:01 dikaso