Skip to content

Commit 071aa0e

Browse files
Fix components not properly closing when using the transition prop (#3448)
This PR fixes a bug where the components don't always properly close when using the `transition` prop on those components. The issue here is that the internal `useTransition(…)` hook relies on a DOM node. Whenever the DOM node changes, we need to re-run the `useTransition(…)`. This is why we store the DOM element in state instead of relying on a `useRef(…)`. Let's say you have a `Popover` component, then the structure looks like this: ```ts <Popover> <PopoverButton>Show</PopoverButton> <PopoverPanel>Contents</PopoverPanel> </Popover> ``` We store a DOM reference to the button and the panel in state, and the state lives in the `Popover` component. The reason we do that is so that the button can reference the panel and the panel can reference the button. This is needed for some `aria-*` attributes for example: ```ts <PopoverButton aria-controls={panelElement.id}> ``` For the transitions, we set some state to make sure that the panel is visible or hidden, then we wait for transitions to finish by listening to transition related events on the DOM node directly. If you now say, "hey panel, please re-render because you have to become visible/hidden" then the component re-renders, the panel DOM node (stored in the `Popover` component) eventually updates and then the `useTransition(…)` hooks receives the new value (either the DOM node or null when the leave transition is complete). The problem here is the round trip that it first has to go to the root `<Popover/>` component, re-render everything and provide the new DOM node to the `useTransition(…)` hook. The solution? Local state so that the panel can re-render on its own and doesn't require the round trip via the parent. Fixes: #3438 Fixes: #3437 Fixes: tailwindlabs/tailwind-plus-issues#1625 --------- Co-authored-by: Jonathan Reinink <[email protected]>
1 parent 2ff8458 commit 071aa0e

File tree

12 files changed

+287
-30
lines changed

12 files changed

+287
-30
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Fixed
11+
12+
- Fix components not closing properly when using the `transition` prop ([#3448](https://github.com/tailwindlabs/headlessui/pull/3448))
1113

1214
## [2.1.3] - 2024-08-23
1315

packages/@headlessui-react/src/components/combobox/combobox.test.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react'
1+
import { render, waitFor } from '@testing-library/react'
22
import React, { Fragment, createElement, useEffect, useState } from 'react'
33
import {
44
ComboboxMode,
@@ -42,7 +42,13 @@ import {
4242
} from '../../test-utils/interactions'
4343
import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
4444
import { Transition } from '../transition/transition'
45-
import { Combobox } from './combobox'
45+
import {
46+
Combobox,
47+
ComboboxButton,
48+
ComboboxInput,
49+
ComboboxOption,
50+
ComboboxOptions,
51+
} from './combobox'
4652

4753
let NOOP = () => {}
4854

@@ -6060,3 +6066,39 @@ describe('Form compatibility', () => {
60606066
])
60616067
})
60626068
})
6069+
6070+
describe('transitions', () => {
6071+
it(
6072+
'should be possible to close the Combobox when using the `transition` prop',
6073+
suppressConsoleLogs(async () => {
6074+
render(
6075+
<Combobox>
6076+
<ComboboxButton>Toggle</ComboboxButton>
6077+
<ComboboxInput />
6078+
<ComboboxOptions transition>
6079+
<ComboboxOption value="alice">Alice</ComboboxOption>
6080+
<ComboboxOption value="bob">Bob</ComboboxOption>
6081+
<ComboboxOption value="charlie">Charlie</ComboboxOption>
6082+
</ComboboxOptions>
6083+
</Combobox>
6084+
)
6085+
6086+
// Open the combobox
6087+
await click(getComboboxButton())
6088+
6089+
// Ensure the combobox is visible
6090+
assertCombobox({ state: ComboboxState.Visible })
6091+
6092+
// Close the combobox
6093+
await click(getComboboxButton())
6094+
6095+
// Wait for the transition to finish, and the combobox to close
6096+
await waitFor(() => {
6097+
assertComboboxList({ state: ComboboxState.InvisibleUnmounted })
6098+
})
6099+
6100+
// Ensure the input got the restored focus
6101+
assertActiveElement(getComboboxInput())
6102+
})
6103+
)
6104+
})

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1672,14 +1672,26 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16721672
}
16731673

16741674
let [floatingRef, style] = useFloatingPanel(anchor)
1675+
1676+
// To improve the correctness of transitions (timing related race conditions),
1677+
// we track the element locally to this component, instead of relying on the
1678+
// context value. This way, the component can re-render independently of the
1679+
// parent component when the `useTransition(…)` hook performs a state change.
1680+
let [localOptionsElement, setLocalOptionsElement] = useState<HTMLElement | null>(null)
1681+
16751682
let getFloatingPanelProps = useFloatingPanelProps()
1676-
let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement)
1683+
let optionsRef = useSyncRefs(
1684+
ref,
1685+
anchor ? floatingRef : null,
1686+
actions.setOptionsElement,
1687+
setLocalOptionsElement
1688+
)
16771689
let ownerDocument = useOwnerDocument(data.optionsElement)
16781690

16791691
let usesOpenClosedState = useOpenClosed()
16801692
let [visible, transitionData] = useTransition(
16811693
transition,
1682-
data.optionsElement,
1694+
localOptionsElement,
16831695
usesOpenClosedState !== null
16841696
? (usesOpenClosedState & State.Open) === State.Open
16851697
: data.comboboxState === ComboboxState.Open

packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
1-
import { render } from '@testing-library/react'
2-
import React, { createElement, Suspense, useEffect, useRef } from 'react'
1+
import { render, waitFor } from '@testing-library/react'
2+
import React, { Suspense, createElement, useEffect, useRef } from 'react'
33
import {
4+
DisclosureState,
45
assertActiveElement,
56
assertDisclosureButton,
67
assertDisclosurePanel,
7-
DisclosureState,
88
getByText,
99
getDisclosureButton,
1010
getDisclosurePanel,
1111
} from '../../test-utils/accessibility-assertions'
12-
import { click, focus, Keys, MouseButton, press } from '../../test-utils/interactions'
12+
import { Keys, MouseButton, click, focus, press } from '../../test-utils/interactions'
1313
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
1414
import { Transition } from '../transition/transition'
15-
import { Disclosure } from './disclosure'
15+
import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure'
1616

1717
jest.mock('../../hooks/use-id')
1818

@@ -985,3 +985,40 @@ describe('Mouse interactions', () => {
985985
})
986986
)
987987
})
988+
989+
describe('transitions', () => {
990+
it(
991+
'should be possible to close the Disclosure when using the `transition` prop',
992+
suppressConsoleLogs(async () => {
993+
render(
994+
<Disclosure>
995+
<DisclosureButton>Toggle</DisclosureButton>
996+
<DisclosurePanel transition>Contents</DisclosurePanel>
997+
</Disclosure>
998+
)
999+
1000+
// Focus the button
1001+
await focus(getDisclosureButton())
1002+
1003+
// Ensure the button is focused
1004+
assertActiveElement(getDisclosureButton())
1005+
1006+
// Open the disclosure
1007+
await click(getDisclosureButton())
1008+
1009+
// Ensure the disclosure is visible
1010+
assertDisclosurePanel({ state: DisclosureState.Visible })
1011+
1012+
// Close the disclosure
1013+
await click(getDisclosureButton())
1014+
1015+
// Wait for the transition to finish, and the disclosure to close
1016+
await waitFor(() => {
1017+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
1018+
})
1019+
1020+
// Ensure the button got the restored focus
1021+
assertActiveElement(getDisclosureButton())
1022+
})
1023+
)
1024+
})

packages/@headlessui-react/src/components/disclosure/disclosure.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import React, {
1111
useMemo,
1212
useReducer,
1313
useRef,
14+
useState,
1415
type ContextType,
1516
type Dispatch,
1617
type ElementType,
@@ -451,11 +452,18 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
451452
let { close } = useDisclosureAPIContext('Disclosure.Panel')
452453
let mergeRefs = useMergeRefsFn()
453454

455+
// To improve the correctness of transitions (timing related race conditions),
456+
// we track the element locally to this component, instead of relying on the
457+
// context value. This way, the component can re-render independently of the
458+
// parent component when the `useTransition(…)` hook performs a state change.
459+
let [localPanelElement, setLocalPanelElement] = useState<HTMLElement | null>(null)
460+
454461
let panelRef = useSyncRefs(
455462
ref,
456463
useEvent((element) => {
457464
startTransition(() => dispatch({ type: ActionTypes.SetPanelElement, element }))
458-
})
465+
}),
466+
setLocalPanelElement
459467
)
460468

461469
useEffect(() => {
@@ -468,7 +476,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
468476
let usesOpenClosedState = useOpenClosed()
469477
let [visible, transitionData] = useTransition(
470478
transition,
471-
state.panelElement,
479+
localPanelElement,
472480
usesOpenClosedState !== null
473481
? (usesOpenClosedState & State.Open) === State.Open
474482
: state.disclosureState === DisclosureStates.Open

packages/@headlessui-react/src/components/listbox/listbox.test.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react'
1+
import { render, waitFor } from '@testing-library/react'
22
import React, { createElement, useEffect, useState } from 'react'
33
import {
44
ListboxMode,
@@ -35,7 +35,7 @@ import {
3535
} from '../../test-utils/interactions'
3636
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
3737
import { Transition } from '../transition/transition'
38-
import { Listbox } from './listbox'
38+
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from './listbox'
3939

4040
jest.mock('../../hooks/use-id')
4141

@@ -4811,3 +4811,44 @@ describe('Form compatibility', () => {
48114811
])
48124812
})
48134813
})
4814+
4815+
describe('transitions', () => {
4816+
it(
4817+
'should be possible to close the Listbox when using the `transition` prop',
4818+
suppressConsoleLogs(async () => {
4819+
render(
4820+
<Listbox>
4821+
<ListboxButton>Toggle</ListboxButton>
4822+
<ListboxOptions transition>
4823+
<ListboxOption value="alice">Alice</ListboxOption>
4824+
<ListboxOption value="bob">Bob</ListboxOption>
4825+
<ListboxOption value="charlie">Charlie</ListboxOption>
4826+
</ListboxOptions>
4827+
</Listbox>
4828+
)
4829+
4830+
// Focus the button
4831+
await focus(getListboxButton())
4832+
4833+
// Ensure the button is focused
4834+
assertActiveElement(getListboxButton())
4835+
4836+
// Open the listbox
4837+
await click(getListboxButton())
4838+
4839+
// Ensure the listbox is visible
4840+
assertListbox({ state: ListboxState.Visible })
4841+
4842+
// Close the listbox
4843+
await click(getListboxButton())
4844+
4845+
// Wait for the transition to finish, and the listbox to close
4846+
await waitFor(() => {
4847+
assertListbox({ state: ListboxState.InvisibleUnmounted })
4848+
})
4849+
4850+
// Ensure the button got the restored focus
4851+
assertActiveElement(getListboxButton())
4852+
})
4853+
)
4854+
})

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React, {
1212
useMemo,
1313
useReducer,
1414
useRef,
15+
useState,
1516
type CSSProperties,
1617
type ElementType,
1718
type MutableRefObject,
@@ -932,6 +933,12 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
932933
} = props
933934
let anchor = useResolvedAnchor(rawAnchor)
934935

936+
// To improve the correctness of transitions (timing related race conditions),
937+
// we track the element locally to this component, instead of relying on the
938+
// context value. This way, the component can re-render independently of the
939+
// parent component when the `useTransition(…)` hook performs a state change.
940+
let [localOptionsElement, setLocalOptionsElement] = useState<HTMLElement | null>(null)
941+
935942
// Always enable `portal` functionality, when `anchor` is enabled
936943
if (anchor) {
937944
portal = true
@@ -945,7 +952,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
945952
let usesOpenClosedState = useOpenClosed()
946953
let [visible, transitionData] = useTransition(
947954
transition,
948-
data.optionsElement,
955+
localOptionsElement,
949956
usesOpenClosedState !== null
950957
? (usesOpenClosedState & State.Open) === State.Open
951958
: data.listboxState === ListboxStates.Open
@@ -1023,7 +1030,12 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10231030

10241031
let [floatingRef, style] = useFloatingPanel(anchorOptions)
10251032
let getFloatingPanelProps = useFloatingPanelProps()
1026-
let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement)
1033+
let optionsRef = useSyncRefs(
1034+
ref,
1035+
anchor ? floatingRef : null,
1036+
actions.setOptionsElement,
1037+
setLocalOptionsElement
1038+
)
10271039

10281040
let searchDisposables = useDisposables()
10291041

packages/@headlessui-react/src/components/menu/menu.test.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render } from '@testing-library/react'
1+
import { render, waitFor } from '@testing-library/react'
22
import React, { createElement, useEffect } from 'react'
33
import {
44
MenuState,
@@ -31,7 +31,7 @@ import {
3131
} from '../../test-utils/interactions'
3232
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
3333
import { Transition } from '../transition/transition'
34-
import { Menu } from './menu'
34+
import { Menu, MenuButton, MenuItem, MenuItems } from './menu'
3535

3636
jest.mock('../../hooks/use-id')
3737

@@ -3531,3 +3531,44 @@ describe('Mouse interactions', () => {
35313531
})
35323532
)
35333533
})
3534+
3535+
describe('transitions', () => {
3536+
it(
3537+
'should be possible to close the Menu when using the `transition` prop',
3538+
suppressConsoleLogs(async () => {
3539+
render(
3540+
<Menu>
3541+
<MenuButton>Toggle</MenuButton>
3542+
<MenuItems transition>
3543+
<MenuItem as="a">Alice</MenuItem>
3544+
<MenuItem as="a">Bob</MenuItem>
3545+
<MenuItem as="a">Charlie</MenuItem>
3546+
</MenuItems>
3547+
</Menu>
3548+
)
3549+
3550+
// Focus the button
3551+
await focus(getMenuButton())
3552+
3553+
// Ensure the button is focused
3554+
assertActiveElement(getMenuButton())
3555+
3556+
// Open the menu
3557+
await click(getMenuButton())
3558+
3559+
// Ensure the menu is visible
3560+
assertMenu({ state: MenuState.Visible })
3561+
3562+
// Close the menu
3563+
await click(getMenuButton())
3564+
3565+
// Wait for the transition to finish, and the menu to close
3566+
await waitFor(() => {
3567+
assertMenu({ state: MenuState.InvisibleUnmounted })
3568+
})
3569+
3570+
// Ensure the button got the restored focus
3571+
assertActiveElement(getMenuButton())
3572+
})
3573+
)
3574+
})

0 commit comments

Comments
 (0)