Skip to content

Commit ef78d58

Browse files
authored
Fix crash when using DisclosureButton inside of a DisclosurePanel when the Disclosure is open by default (#3465)
This PR fixes an issue where React hooks were called unconditionally> The `PopoverButton` and `DisclosureButton` act as a `CloseButton` when used inside of a panel. We conditionally handled the `ref` when it's inside a panel. To ensure that the callback is stable, the conditionally used function was wrapped in a `useEvent(…)` hook. This seemed to be ok (even though we break the rules of hooks) because a button can only be in a panel or not be in a panel, but it can't switch during the lifetime of the button. Aka, the rules of hooks are not broken because all code paths lead to the same hooks being called. ```ts <Disclosure defaultOpen> <DisclosureButton>Open</DisclosureButton> <DisclosurePanel> <DisclosureButton>Close</DisclosureButton> </DisclosurePanel> <Disclosure> ``` But... it can be called conditionally, because the way we know whether we are in a panel relies on a state value which comes from context and is populated by a `useEffect(…)` hook. The reason we didn't catch this in the `Disclosure` component, is because all the state is stable and known by the time the `DisclosurePanel` opens. But if you use the `defaultOpen` prop, the `DisclosurePanel` is already open and then the state is not ready yet (because we have to wait for the `useEffect(…)` hook). Long story short, moved the `isWithinPanel` check inside the `useEvent(…)` hook that holds the stable function which means that we don't call this hook unconditionally anymore.
1 parent 07c9f1f commit ef78d58

File tree

4 files changed

+63
-19
lines changed

4 files changed

+63
-19
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Fix `ListboxOptions` being incorrectly marked as `inert` ([#3466](https://github.com/tailwindlabs/headlessui/pull/3466))
13+
- Fix crash when using `DisclosureButton` inside of a `DisclosurePanel` when the `Disclosure` is open by default ([#3465](https://github.com/tailwindlabs/headlessui/pull/3465))
1314

1415
## [2.1.5] - 2024-09-04
1516

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,49 @@ describe('Rendering', () => {
337337
})
338338
)
339339

340+
it('should behave as a close button when used inside of the Disclosure.Panel', async () => {
341+
render(
342+
<Disclosure>
343+
<Disclosure.Button>Open</Disclosure.Button>
344+
<Disclosure.Panel>
345+
<Disclosure.Button>Close</Disclosure.Button>
346+
</Disclosure.Panel>
347+
</Disclosure>
348+
)
349+
350+
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
351+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
352+
353+
await click(getByText('Open'))
354+
355+
assertDisclosureButton({ state: DisclosureState.Visible })
356+
assertDisclosurePanel({ state: DisclosureState.Visible })
357+
358+
await click(getByText('Close'))
359+
360+
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
361+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
362+
})
363+
364+
it('should behave as a close button when used inside of the Disclosure.Panel (defaultOpen)', async () => {
365+
render(
366+
<Disclosure defaultOpen>
367+
<Disclosure.Button>Open</Disclosure.Button>
368+
<Disclosure.Panel>
369+
<Disclosure.Button>Close</Disclosure.Button>
370+
</Disclosure.Panel>
371+
</Disclosure>
372+
)
373+
374+
assertDisclosureButton({ state: DisclosureState.Visible })
375+
assertDisclosurePanel({ state: DisclosureState.Visible })
376+
377+
await click(getByText('Close'))
378+
379+
assertDisclosureButton({ state: DisclosureState.InvisibleUnmounted })
380+
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
381+
})
382+
340383
describe('`type` attribute', () => {
341384
it('should set the `type` to "button" by default', async () => {
342385
render(

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,10 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
299299
let buttonRef = useSyncRefs(
300300
internalButtonRef,
301301
ref,
302-
!isWithinPanel
303-
? useEvent((element) => dispatch({ type: ActionTypes.SetButtonElement, element }))
304-
: null
302+
useEvent((element) => {
303+
if (isWithinPanel) return
304+
return dispatch({ type: ActionTypes.SetButtonElement, element })
305+
})
305306
)
306307
let mergeRefs = useMergeRefsFn()
307308

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

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -535,24 +535,23 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
535535
internalButtonRef,
536536
ref,
537537
useFloatingReference(),
538-
isWithinPanel
539-
? null
540-
: useEvent((button) => {
541-
if (button) {
542-
state.buttons.current.push(uniqueIdentifier)
543-
} else {
544-
let idx = state.buttons.current.indexOf(uniqueIdentifier)
545-
if (idx !== -1) state.buttons.current.splice(idx, 1)
546-
}
538+
useEvent((button) => {
539+
if (isWithinPanel) return
540+
if (button) {
541+
state.buttons.current.push(uniqueIdentifier)
542+
} else {
543+
let idx = state.buttons.current.indexOf(uniqueIdentifier)
544+
if (idx !== -1) state.buttons.current.splice(idx, 1)
545+
}
547546

548-
if (state.buttons.current.length > 1) {
549-
console.warn(
550-
'You are already using a <Popover.Button /> but only 1 <Popover.Button /> is supported.'
551-
)
552-
}
547+
if (state.buttons.current.length > 1) {
548+
console.warn(
549+
'You are already using a <Popover.Button /> but only 1 <Popover.Button /> is supported.'
550+
)
551+
}
553552

554-
button && dispatch({ type: ActionTypes.SetButton, button })
555-
})
553+
button && dispatch({ type: ActionTypes.SetButton, button })
554+
})
556555
)
557556
let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref)
558557
let ownerDocument = useOwnerDocument(internalButtonRef)

0 commit comments

Comments
 (0)