-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Fix Maximum update depth exceeded due to Transition
#3782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
7b82e5e to
e166008
Compare
40486f1 to
0391845
Compare
0391845 to
1a14a58
Compare
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.
101176e to
5cb51f9
Compare
Co-authored-by: Jordan Pittman <[email protected]>
thecrypticace
approved these changes
Sep 3, 2025
This was referenced Sep 3, 2025
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This PR fixes an issue where you could run into a
Maximum update depth exceedederror in theListbox,MenuorComboboxcomponent in combination with thetransitionprop.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 exceededI could reproduce).To reproduce it, you don't need a lot of code, but you do need a particular setup:
This setup means that you have to scroll down to see the Listbox. When you open the Listbox, the
ListboxOptionswill 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
ListboxButtonmoved 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:
Screen.Recording.2025-09-02.at.13.18.19.mov
Example 2:
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.
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
transitionprop. 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 thebuttonwill be marked as moved.However, we keep checking whether the button moved compared to the initial position, which could result in a loop:
visiblestate (thanks to thetransition) is still set to true.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
enabledboolean (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
useDidElementMovehook entirely.Test plan
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