Skip to content

Conversation

@RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Sep 2, 2025

This PR fixes an issue where you could run into a Maximum update depth exceeded error in the Listbox, Menu or Combobox component in combination with the transition prop.

It seems like some people ran into this, but I was never able to reproduce it. But now I figured out what the issue is, how to reproduce it and this PR should fix it as well (it at least fixes the Maximum update depth exceeded I could reproduce).

To reproduce it, you don't need a lot of code, but you do need a particular setup:

import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
import { useState } from 'react'

let options = ['apple', 'banana', 'orange', 'grape', 'pear']

export default function App() {
  let [value, setValue] = useState('apple')

  return (
    <>
      {/* Required to reproduce the issue */}
      <div className="min-h-dvh" />

      <Listbox value={value} onChange={setValue}>
        <ListboxButton>{value}</ListboxButton>
        <ListboxOptions transition>
          {options.map((option) => (
            <ListboxOption key={option} value={option}>
              {option}
            </ListboxOption>
          ))}
        </ListboxOptions>
      </Listbox>
    </>
  )
}

This setup means that you have to scroll down to see the Listbox. When you open the Listbox, the ListboxOptions will be rendered increasing the height of the page. If you then close it again, the page will decrease in height again, causing the issue to happen.

The next question is, why does the crash happen?

The crash happens because we are updating some internal state based on whether the ListboxButton moved or not. The reason we track whether the button moved or not is to improve the UX (a bit ironic I know, because it could crash…)

In the Listbox component (and some others like Menu and Combobox), we track whether a button moved on the screen when you close the dropdown. If it does move, we will immediately hide the Listbox without any transitions.

This is done to improve the UX because we don't want to see a fading-out dropdown somewhere else on the screen.

Maybe it's easier if I show you an example. This example is using our UI Kit, Catalyst: https://catalyst.tailwindui.com/docs/listbox

Example 1:

  1. Open the dropdown
  2. Click outside or press Escape to close it
  3. Notice the subtle fade-out transition
Screen.Recording.2025-09-02.at.13.18.19.mov

Example 2:

  1. Open the dropdown
  2. Press tab to focus the next element on the page
  3. Notice the subtle fade-out transition
Screen.Recording.2025-09-02.at.13.19.53.mov

Example 3:

Now we're going to do something very similar, but this time the next focusable element will be just outside of what is visual to the user. This will cause the browser to scroll and scroll the new element into view. We'll make the page smaller by opening devtools.

  1. Open the dropdown
  2. Press tab to focus the next element on the page
  3. Notice how the the dropdown is completely gone, no transitions whatsoever
Screen.Recording.2025-09-02.at.15.03.54.mov

Example 4:

Up until now, all these make sense as a user. But here is an example of what would happen if we didn't close the dropdown immediately when the button moved.

Same steps and similar setup as Example 3, but without the special code that detects button moves.

It's going to be quick, but look near the end of this video. Once I press tab, you will see the dropdown fade out and linger around near the top of the screen.

Screen.Recording.2025-09-02.at.13.42.47.mov

(This was a little tricky to reproduce here because when you tab, the active element will be scrolled into view automatically by the browser, so the button sits in the middle of the page. But we want to make sure the dropdown button is still in the viewport~ish. This is why it's zoomed out a bit.)


Now that we established that, why did it crash in some scenarios?

The first important part is the transition prop. A transition allows us to keep the dropdown visible even though its internal state is "closed". This is important so that we will wait until all CSS transitions are done before actually unmounting the component.

The next important part is about the extra div (<div class="min-h-dvh" />) in the reproduction code, because it will force a scrollbar. Opening and closing the listbox will change the height of the page. This in turn will move the button up to make room for the dropdown part, therefore the button will be marked as moved.

However, we keep checking whether the button moved compared to the initial position, which could result in a loop:

  1. Close the listbox.
  2. Compute button position at position A
  3. Re-render happens, and button is now at position B because the dropdown part is gone
  4. Re-render happens, and button is now at position A again, because it's considered at the same initial position so instead of instantly hiding we actually show it again because the visible state (thanks to the transition) is still set to true.
  5. Re-render happens, and button is now at position B again because now the dropdown is rendered again
  6. position A
  7. position B

To actually solve the issue, we have to make sure that once the button is marked as moved, it will stay marked as "moved" until the enabled boolean (argument to the hook) changes again.

I also did some internal refactors by moving this logic into the existing state machines instead of relying on React and when things render. I therefore also got rid of the useDidElementMove hook entirely.

Test plan

  1. Error does not reproduce with this code.
  2. The expected behavior in Catalyst still works as expected.

Simplified reproduction of the crash in action:

Before

Screen.Recording.2025-09-02.at.13.04.55.mov

After

Screen.Recording.2025-09-02.at.13.05.19.mov

Fixes: #3655
Fixes: #3562

@vercel
Copy link

vercel bot commented Sep 2, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
headlessui-react Ready Ready Preview Comment Sep 3, 2025 0:57am
headlessui-vue Ready Ready Preview Comment Sep 3, 2025 0:57am

Before, when we detected that an element moved, we kept checking it
against a previous position. This meant that it could toggle between
`true` and `false`.

With this fix, we ensure that once an element moved, it stays marked as
"moved" preventing a `true`, `false`, `true`, ... loop.

We do use `setState`-like calls in Render, but we ensure that the state
is in a correct state before we call setState in render. This pattern is
also used in the React docs: https://react.dev/reference/react/useState#storing-information-from-previous-renders
We were using values in the dependencies array that would cause new
functions from being returned even though we didn't use those values
inside the callback.
Instead of relying on the React lifecycle and re-renders we can make use
of the state machine model outside of React. This will allow us to
detect button movements as fast as possible without waiting for
re-renders.

I think ideally we move this new code into a nested state machine, but
then we need a way to trigger state changes in parent state machines
because the selectors we would rely on nested state but would also
bailout because the main state didn't change.

We could add the code for that _or_ switch to xstate, but in either case
that would involve a lot of refactoring.
@RobinMalfait RobinMalfait merged commit e475a82 into main Sep 3, 2025
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Maximum update depth exceeded due to Transition <Listbox> error: Uncaught Error: Maximum update depth exceeded.

3 participants