diff --git a/packages/@headlessui-vue/src/components/portal/portal.test.ts b/packages/@headlessui-vue/src/components/portal/portal.test.ts index e333c72dd5..6c73530ab8 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.test.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.test.ts @@ -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() { @@ -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 } @@ -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` + + +
Hello
+
+
+ + + `, + 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) +}) diff --git a/packages/@headlessui-vue/src/components/portal/portal.ts b/packages/@headlessui-vue/src/components/portal/portal.ts index dc09821fa1..ae4b6bb84d 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.ts @@ -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' @@ -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 })