Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/@headlessui-vue/src/components/portal/portal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderToString } from 'vue/server-renderer'
import { html } from '../../test-utils/html'
import { click } from '../../test-utils/interactions'
import { createRenderTemplate } from '../../test-utils/vue-testing-library'
import { TransitionRoot } from '../transitions/transition'
import { Portal, PortalGroup } from './portal'

function getPortalRoot() {
Expand All @@ -22,7 +23,7 @@ beforeAll(() => {

afterAll(() => jest.restoreAllMocks())

const renderTemplate = createRenderTemplate({ Portal, PortalGroup })
const renderTemplate = createRenderTemplate({ Portal, PortalGroup, TransitionRoot })

async function ssrRenderTemplate(input: string | ComponentOptionsWithoutProps) {
let defaultComponents = { Portal, PortalGroup }
Expand Down Expand Up @@ -436,3 +437,40 @@ it('the root shared by multiple portals should not unmount when they change in t
// The portal root is gone because there are no visible portals
expect(root()).toBe(null)
})

it('should render portal content synchronously so that transitions can apply enter classes', async () => {
// Regression test for https://github.com/tailwindlabs/headlessui/issues/3456
//
// When the portal defers rendering to `onMounted`, the Teleport content
// appears one tick after the Transition's enter phase has started. The
// enter-from classes never get applied, so the transition is invisible.
renderTemplate({
template: html`
<TransitionRoot :show="show" enter="enter" enterFrom="enter-from" enterTo="enter-to">
<Portal>
<div id="transitioned">Hello</div>
</Portal>
</TransitionRoot>

<button id="toggle" @click="show = !show">Toggle</button>
`,
setup() {
let show = ref(false)
return { show }
},
})

// Portal root shouldn't exist yet (nothing shown)
expect(getPortalRoot()).toBe(null)

// Toggle show to true — the portal content should render and the
// transition should apply enter-from classes in the same tick
await click(document.getElementById('toggle'))

// The portal should have rendered
let content = document.getElementById('transitioned')
expect(content).not.toBe(null)

// The portal root should exist
expect(getPortalRoot()).not.toBe(null)
})
13 changes: 12 additions & 1 deletion packages/@headlessui-vue/src/components/portal/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from 'vue'
import { usePortalRoot } from '../../internal/portal-force-root'
import { dom } from '../../utils/dom'
import { env } from '../../utils/env'
import { getOwnerDocument } from '../../utils/owner'
import { render } from '../../utils/render'

Expand Down Expand Up @@ -89,7 +90,17 @@ export let Portal = defineComponent({
setCount(myTarget.value, (val) => val + 1)
}

let ready = ref(false)
// Start ready on the client so that the portal renders synchronously
// with the component tree. Deferring to `onMounted` causes a one-tick
// delay that races against Vue 3.5+'s Transition lifecycle — the
// enter-from classes never get applied because the element appears
// after the transition's enter phase has already started.
//
// On the server we must remain `false` to avoid SSR hydration
// mismatches (portals can't render server-side).
//
// Fixes: https://github.com/tailwindlabs/headlessui/issues/3456
let ready = ref(env.isClient)
onMounted(() => {
ready.value = true
})
Expand Down