Fix Portal SSR hydration mismatches (#2700)
* Register portal based on element presence in the DOM This always coincides with `onMounted` currently but that’s about to change * Mount element lazily for portals This prevent’s SSR hydration issues and matches the behavior of React’s `<Portal>` element * Fix portal tests * Update comment * Update changelog
This commit is contained in:
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Don't call `<Dialog>`'s `onClose` twice on mobile devices ([#2690](https://github.com/tailwindlabs/headlessui/pull/2690))
|
||||
- Fix Portal SSR hydration mismatches ([#2700](https://github.com/tailwindlabs/headlessui/pull/2700))
|
||||
|
||||
## [1.7.16] - 2023-08-17
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ it('SSR-rendering a Portal should not error', async () => {
|
||||
expect(result).toBe(html`<main id="parent"><!----></main>`)
|
||||
})
|
||||
|
||||
it('should be possible to use a Portal', () => {
|
||||
it('should be possible to use a Portal', async () => {
|
||||
expect(getPortalRoot()).toBe(null)
|
||||
|
||||
renderTemplate(
|
||||
@@ -112,6 +112,8 @@ it('should be possible to use a Portal', () => {
|
||||
`
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
let parent = document.getElementById('parent')
|
||||
let content = document.getElementById('content')
|
||||
|
||||
@@ -125,7 +127,7 @@ it('should be possible to use a Portal', () => {
|
||||
expect(content).toHaveTextContent('Contents...')
|
||||
})
|
||||
|
||||
it('should be possible to use multiple Portal elements', () => {
|
||||
it('should be possible to use multiple Portal elements', async () => {
|
||||
expect(getPortalRoot()).toBe(null)
|
||||
|
||||
renderTemplate(
|
||||
@@ -142,6 +144,8 @@ it('should be possible to use multiple Portal elements', () => {
|
||||
`
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
let parent = document.getElementById('parent')
|
||||
let content1 = document.getElementById('content1')
|
||||
let content2 = document.getElementById('content2')
|
||||
@@ -284,6 +288,8 @@ it('should be possible to render multiple portals at the same time', async () =>
|
||||
},
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(getPortalRoot()).not.toBe(null)
|
||||
expect(getPortalRoot().children).toHaveLength(3)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
InjectionKey,
|
||||
PropType,
|
||||
Ref,
|
||||
watch,
|
||||
} from 'vue'
|
||||
import { render } from '../../utils/render'
|
||||
import { usePortalRoot } from '../../internal/portal-force-root'
|
||||
@@ -63,6 +64,11 @@ export let Portal = defineComponent({
|
||||
: groupContext.resolveTarget()
|
||||
)
|
||||
|
||||
let ready = ref(false)
|
||||
onMounted(() => {
|
||||
ready.value = true
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (forcePortalRoot) return
|
||||
if (groupContext == null) return
|
||||
@@ -70,12 +76,18 @@ export let Portal = defineComponent({
|
||||
})
|
||||
|
||||
let parent = inject(PortalParentContext, null)
|
||||
onMounted(() => {
|
||||
|
||||
// Since the element is mounted lazily (because of SSR hydration)
|
||||
// We use `watch` on `element` + a local var rather than
|
||||
// `onMounted` to ensure registration only happens once
|
||||
let didRegister = false
|
||||
watch(element, () => {
|
||||
if (didRegister) return
|
||||
if (!parent) return
|
||||
let domElement = dom(element)
|
||||
if (!domElement) return
|
||||
if (!parent) return
|
||||
|
||||
onUnmounted(parent.register(domElement))
|
||||
didRegister = true
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -89,6 +101,7 @@ export let Portal = defineComponent({
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (!ready.value) return null
|
||||
if (myTarget.value === null) return null
|
||||
|
||||
let ourProps = {
|
||||
|
||||
Reference in New Issue
Block a user