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