From 7d923a7e6edbd5a4b163e178ced1b516cc2f6fc1 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 3 Sep 2024 14:02:12 +0200 Subject: [PATCH 1/5] add tests to verify that closing elements with `transition` prop works --- .../src/components/combobox/combobox.test.tsx | 46 +++++++++++++++++- .../components/disclosure/disclosure.test.tsx | 47 +++++++++++++++++-- .../src/components/listbox/listbox.test.tsx | 45 +++++++++++++++++- .../src/components/menu/menu.test.tsx | 45 +++++++++++++++++- .../src/components/popover/popover.test.tsx | 39 ++++++++++++++- 5 files changed, 210 insertions(+), 12 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 00fb4953ee..2eb66609a0 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import React, { Fragment, createElement, useEffect, useState } from 'react' import { ComboboxMode, @@ -42,7 +42,13 @@ import { } from '../../test-utils/interactions' import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { Transition } from '../transition/transition' -import { Combobox } from './combobox' +import { + Combobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from './combobox' let NOOP = () => {} @@ -6060,3 +6066,39 @@ describe('Form compatibility', () => { ]) }) }) + +describe('transitions', () => { + it( + 'should be possible to close the Combobox when using the `transition` prop', + suppressConsoleLogs(async () => { + render( + + Toggle + + + Alice + Bob + Charlie + + + ) + + // Open the combobox + await click(getComboboxButton()) + + // Ensure the combobox is visible + assertCombobox({ state: ComboboxState.Visible }) + + // Close the combobox + await click(getComboboxButton()) + + // Wait for the transition to finish, and the combobox to close + await waitFor(() => { + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + + // Ensure the input got the restored focus + assertActiveElement(getComboboxInput()) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 2c7aeb29df..d32844943c 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -1,18 +1,18 @@ -import { render } from '@testing-library/react' -import React, { createElement, Suspense, useEffect, useRef } from 'react' +import { render, waitFor } from '@testing-library/react' +import React, { Suspense, createElement, useEffect, useRef } from 'react' import { + DisclosureState, assertActiveElement, assertDisclosureButton, assertDisclosurePanel, - DisclosureState, getByText, getDisclosureButton, getDisclosurePanel, } from '../../test-utils/accessibility-assertions' -import { click, focus, Keys, MouseButton, press } from '../../test-utils/interactions' +import { Keys, MouseButton, click, focus, press } from '../../test-utils/interactions' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { Transition } from '../transition/transition' -import { Disclosure } from './disclosure' +import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure' jest.mock('../../hooks/use-id') @@ -985,3 +985,40 @@ describe('Mouse interactions', () => { }) ) }) + +describe('transitions', () => { + it( + 'should be possible to close the Disclosure when using the `transition` prop', + suppressConsoleLogs(async () => { + render( + + Toggle + Contents + + ) + + // Focus the button + await focus(getDisclosureButton()) + + // Ensure the button is focused + assertActiveElement(getDisclosureButton()) + + // Open the disclosure + await click(getDisclosureButton()) + + // Ensure the disclosure is visible + assertDisclosurePanel({ state: DisclosureState.Visible }) + + // Close the disclosure + await click(getDisclosureButton()) + + // Wait for the transition to finish, and the disclosure to close + await waitFor(() => { + assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted }) + }) + + // Ensure the button got the restored focus + assertActiveElement(getDisclosureButton()) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index f603344b52..d75b01f5e3 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import React, { createElement, useEffect, useState } from 'react' import { ListboxMode, @@ -35,7 +35,7 @@ import { } from '../../test-utils/interactions' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { Transition } from '../transition/transition' -import { Listbox } from './listbox' +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from './listbox' jest.mock('../../hooks/use-id') @@ -4811,3 +4811,44 @@ describe('Form compatibility', () => { ]) }) }) + +describe('transitions', () => { + it( + 'should be possible to close the Listbox when using the `transition` prop', + suppressConsoleLogs(async () => { + render( + + Toggle + + Alice + Bob + Charlie + + + ) + + // Focus the button + await focus(getListboxButton()) + + // Ensure the button is focused + assertActiveElement(getListboxButton()) + + // Open the listbox + await click(getListboxButton()) + + // Ensure the listbox is visible + assertListbox({ state: ListboxState.Visible }) + + // Close the listbox + await click(getListboxButton()) + + // Wait for the transition to finish, and the listbox to close + await waitFor(() => { + assertListbox({ state: ListboxState.InvisibleUnmounted }) + }) + + // Ensure the button got the restored focus + assertActiveElement(getListboxButton()) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 0285959de9..a57e748f88 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import React, { createElement, useEffect } from 'react' import { MenuState, @@ -31,7 +31,7 @@ import { } from '../../test-utils/interactions' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { Transition } from '../transition/transition' -import { Menu } from './menu' +import { Menu, MenuButton, MenuItem, MenuItems } from './menu' jest.mock('../../hooks/use-id') @@ -3531,3 +3531,44 @@ describe('Mouse interactions', () => { }) ) }) + +describe('transitions', () => { + it( + 'should be possible to close the Menu when using the `transition` prop', + suppressConsoleLogs(async () => { + render( + + Toggle + + Alice + Bob + Charlie + + + ) + + // Focus the button + await focus(getMenuButton()) + + // Ensure the button is focused + assertActiveElement(getMenuButton()) + + // Open the menu + await click(getMenuButton()) + + // Ensure the menu is visible + assertMenu({ state: MenuState.Visible }) + + // Close the menu + await click(getMenuButton()) + + // Wait for the transition to finish, and the menu to close + await waitFor(() => { + assertMenu({ state: MenuState.InvisibleUnmounted }) + }) + + // Ensure the button got the restored focus + assertActiveElement(getMenuButton()) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index 85d00d0f71..86d9d44841 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, waitFor } from '@testing-library/react' import React, { Fragment, act, createElement, useEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' import { @@ -2844,3 +2844,40 @@ describe('Nested popovers', () => { }) ) }) + +describe('transitions', () => { + it( + 'should be possible to close the Popover when using the `transition` prop', + suppressConsoleLogs(async () => { + render( + + Toggle + Contents + + ) + + // Focus the button + await focus(getPopoverButton()) + + // Ensure the button is focused + assertActiveElement(getPopoverButton()) + + // Open the popover + await click(getPopoverButton()) + + // Ensure the popover is visible + assertPopoverPanel({ state: PopoverState.Visible }) + + // Close the popover + await click(getPopoverButton()) + + // Wait for the transition to finish, and the popover to close + await waitFor(() => { + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + }) + + // Ensure the button got the restored focus + assertActiveElement(getPopoverButton()) + }) + ) +}) From c5c0682fb2a0cbb48cbe251fa0f13eb4e2ed264e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 3 Sep 2024 12:13:24 +0200 Subject: [PATCH 2/5] use local state for transitions This now relies on local state for transitions so that when the local state changes, the current component can re-render on its own already. Relying on state from the parent via context, requires the full component to re-render first which is not efficient enough for this case. We still need that more global state for cases where we want to reference an element in another component (e.g.: reference the panel id in a button's aria-controls attribute) --- .../src/components/combobox/combobox.tsx | 10 ++++++++-- .../src/components/disclosure/disclosure.tsx | 7 +++++-- .../src/components/listbox/listbox.tsx | 11 +++++++++-- .../src/components/menu/menu.tsx | 8 ++++++-- .../src/components/popover/popover.tsx | 17 +++++++++++------ .../src/components/transition/transition.tsx | 6 +++--- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 131284aa31..a76f893867 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1672,14 +1672,20 @@ function OptionsFn( } let [floatingRef, style] = useFloatingPanel(anchor) + let [localOptionsElement, setLocalOptionsElement] = useState(null) let getFloatingPanelProps = useFloatingPanelProps() - let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement) + let optionsRef = useSyncRefs( + ref, + anchor ? floatingRef : null, + actions.setOptionsElement, + setLocalOptionsElement + ) let ownerDocument = useOwnerDocument(data.optionsElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - data.optionsElement, + localOptionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : data.comboboxState === ComboboxState.Open diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 4a20bb2e5e..743deefb63 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -11,6 +11,7 @@ import React, { useMemo, useReducer, useRef, + useState, type ContextType, type Dispatch, type ElementType, @@ -450,12 +451,14 @@ function PanelFn( let [state, dispatch] = useDisclosureContext('Disclosure.Panel') let { close } = useDisclosureAPIContext('Disclosure.Panel') let mergeRefs = useMergeRefsFn() + let [localPanelElement, setLocalPanelElement] = useState(null) let panelRef = useSyncRefs( ref, useEvent((element) => { startTransition(() => dispatch({ type: ActionTypes.SetPanelElement, element })) - }) + }), + setLocalPanelElement ) useEffect(() => { @@ -468,7 +471,7 @@ function PanelFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - state.panelElement, + localPanelElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.disclosureState === DisclosureStates.Open diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index e8c40e4365..78d8c15694 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -12,6 +12,7 @@ import React, { useMemo, useReducer, useRef, + useState, type CSSProperties, type ElementType, type MutableRefObject, @@ -931,6 +932,7 @@ function OptionsFn( ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) + let [localOptionsElement, setLocalOptionsElement] = useState(null) // Always enable `portal` functionality, when `anchor` is enabled if (anchor) { @@ -945,7 +947,7 @@ function OptionsFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - data.optionsElement, + localOptionsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : data.listboxState === ListboxStates.Open @@ -1023,7 +1025,12 @@ function OptionsFn( let [floatingRef, style] = useFloatingPanel(anchorOptions) let getFloatingPanelProps = useFloatingPanelProps() - let optionsRef = useSyncRefs(ref, anchor ? floatingRef : null, actions.setOptionsElement) + let optionsRef = useSyncRefs( + ref, + anchor ? floatingRef : null, + actions.setOptionsElement, + setLocalOptionsElement + ) let searchDisposables = useDisposables() diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 8da007529a..655f7cdf8a 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -12,6 +12,7 @@ import React, { useMemo, useReducer, useRef, + useState, type CSSProperties, type Dispatch, type ElementType, @@ -620,10 +621,13 @@ function ItemsFn( let [state, dispatch] = useMenuContext('Menu.Items') let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() + let [localItemsElement, setLocalItemsElement] = useState(null) + let itemsRef = useSyncRefs( ref, anchor ? floatingRef : null, - useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })) + useEvent((element) => dispatch({ type: ActionTypes.SetItemsElement, element })), + setLocalItemsElement ) let ownerDocument = useOwnerDocument(state.itemsElement) @@ -635,7 +639,7 @@ function ItemsFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - state.itemsElement, + localItemsElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.menuState === MenuStates.Open diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 018368d167..2c130b4519 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -763,13 +763,13 @@ function BackdropFn( ...theirProps } = props let [{ popoverState }, dispatch] = usePopoverContext('Popover.Backdrop') - let [backdropElement, setBackdropElement] = useState(null) - let backdropRef = useSyncRefs(ref, setBackdropElement) + let [localBackdropElement, setLocalBackdropElement] = useState(null) + let backdropRef = useSyncRefs(ref, setLocalBackdropElement) let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - backdropElement, + localBackdropElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : popoverState === PopoverStates.Open @@ -865,11 +865,13 @@ function PanelFn( portal = true } + let [localPanelElement, setLocalPanelElement] = useState(null) let panelRef = useSyncRefs( internalPanelRef, ref, anchor ? floatingRef : null, - useEvent((panel) => dispatch({ type: ActionTypes.SetPanel, panel })) + useEvent((panel) => dispatch({ type: ActionTypes.SetPanel, panel })), + setLocalPanelElement ) let ownerDocument = useOwnerDocument(internalPanelRef) let mergeRefs = useMergeRefsFn() @@ -884,7 +886,7 @@ function PanelFn( let usesOpenClosedState = useOpenClosed() let [visible, transitionData] = useTransition( transition, - state.panel, + localPanelElement, usesOpenClosedState !== null ? (usesOpenClosedState & State.Open) === State.Open : state.popoverState === PopoverStates.Open @@ -1028,7 +1030,10 @@ function PanelFn( // Ignore sentinel buttons and items inside the panel for (let element of combined.slice()) { - if (element.dataset.headlessuiFocusGuard === 'true' || state.panel?.contains(element)) { + if ( + element.dataset.headlessuiFocusGuard === 'true' || + localPanelElement?.contains(element) + ) { let idx = combined.indexOf(element) if (idx !== -1) combined.splice(idx, 1) } diff --git a/packages/@headlessui-react/src/components/transition/transition.tsx b/packages/@headlessui-react/src/components/transition/transition.tsx index 31aaf07a4c..9f0922f0a3 100644 --- a/packages/@headlessui-react/src/components/transition/transition.tsx +++ b/packages/@headlessui-react/src/components/transition/transition.tsx @@ -319,12 +319,12 @@ function TransitionChildFn(null) + let [localContainerElement, setLocalContainerElement] = useState(null) let container = useRef(null) let requiresRef = shouldForwardRef(props) let transitionRef = useSyncRefs( - ...(requiresRef ? [container, ref, setContainerElement] : ref === null ? [] : [ref]) + ...(requiresRef ? [container, ref, setLocalContainerElement] : ref === null ? [] : [ref]) ) let strategy = theirProps.unmount ?? true ? RenderStrategy.Unmount : RenderStrategy.Hidden @@ -438,7 +438,7 @@ function TransitionChildFn` is done, but there is still a // child `` busy, then `visible` would be `false`, while // `state` would still be `TreeStates.Visible`. - let [, transitionData] = useTransition(enabled, containerElement, show, { start, end }) + let [, transitionData] = useTransition(enabled, localContainerElement, show, { start, end }) let ourProps = compact({ ref: transitionRef, From 2fbdc963017a518caa46349082a6d2499154f23a Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 3 Sep 2024 12:59:42 +0200 Subject: [PATCH 3/5] update changelog --- packages/@headlessui-react/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 89fb6bc26c..4e6ba2c498 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Fix components not properly closing when using the `transition` prop ([#3448](https://github.com/tailwindlabs/headlessui/pull/3448)) ## [2.1.3] - 2024-08-23 From 40fff732f033eb53dd9161c55232368f5bf5433d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 3 Sep 2024 16:48:18 +0200 Subject: [PATCH 4/5] Update packages/@headlessui-react/CHANGELOG.md Co-authored-by: Jonathan Reinink --- packages/@headlessui-react/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 4e6ba2c498..a8af42d98d 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Fix components not properly closing when using the `transition` prop ([#3448](https://github.com/tailwindlabs/headlessui/pull/3448)) +- Fix components not closing properly when using the `transition` prop ([#3448](https://github.com/tailwindlabs/headlessui/pull/3448)) ## [2.1.3] - 2024-08-23 From 18d45097bdbf82793e6a72dc84dc2d769d96cdfc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 3 Sep 2024 17:13:34 +0200 Subject: [PATCH 5/5] add comments why we track the value locally --- .../src/components/combobox/combobox.tsx | 6 ++++++ .../src/components/disclosure/disclosure.tsx | 5 +++++ .../src/components/listbox/listbox.tsx | 5 +++++ .../@headlessui-react/src/components/menu/menu.tsx | 5 +++++ .../src/components/popover/popover.tsx | 11 +++++++++++ 5 files changed, 32 insertions(+) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index a76f893867..8e582a0349 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1672,7 +1672,13 @@ function OptionsFn( } let [floatingRef, style] = useFloatingPanel(anchor) + + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localOptionsElement, setLocalOptionsElement] = useState(null) + let getFloatingPanelProps = useFloatingPanelProps() let optionsRef = useSyncRefs( ref, diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 743deefb63..2f3e522627 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -451,6 +451,11 @@ function PanelFn( let [state, dispatch] = useDisclosureContext('Disclosure.Panel') let { close } = useDisclosureAPIContext('Disclosure.Panel') let mergeRefs = useMergeRefsFn() + + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localPanelElement, setLocalPanelElement] = useState(null) let panelRef = useSyncRefs( diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 78d8c15694..e82cad3475 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -932,6 +932,11 @@ function OptionsFn( ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) + + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localOptionsElement, setLocalOptionsElement] = useState(null) // Always enable `portal` functionality, when `anchor` is enabled diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 655f7cdf8a..88b0074740 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -621,6 +621,11 @@ function ItemsFn( let [state, dispatch] = useMenuContext('Menu.Items') let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() + + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localItemsElement, setLocalItemsElement] = useState(null) let itemsRef = useSyncRefs( diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 2c130b4519..b1efa7eaf6 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -763,7 +763,13 @@ function BackdropFn( ...theirProps } = props let [{ popoverState }, dispatch] = usePopoverContext('Popover.Backdrop') + + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localBackdropElement, setLocalBackdropElement] = useState(null) + let backdropRef = useSyncRefs(ref, setLocalBackdropElement) let usesOpenClosedState = useOpenClosed() @@ -865,7 +871,12 @@ function PanelFn( portal = true } + // To improve the correctness of transitions (timing related race conditions), + // we track the element locally to this component, instead of relying on the + // context value. This way, the component can re-render independently of the + // parent component when the `useTransition(…)` hook performs a state change. let [localPanelElement, setLocalPanelElement] = useState(null) + let panelRef = useSyncRefs( internalPanelRef, ref,