fix: prevent parent drawers from closing when canceling nested drawers #1748
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.
What does this PR do?
Fixes #1624 — an issue where clicking the "Cancel" button in a nested drawer unintentionally closed its parent drawer.
<Drawer.NestedRoot />
API. Instead, they are treated as siblings in the DOM structure. Refer to the Vaul documentation on nested drawers for details.onPointerDownOutside
Behavior: Radix dialogs and Vaul drawers trigger theonPointerDownOutside
callback when a click occurs outside these components. This callback can be leveraged to prevent unintended dismissals.Based on my analysis, the issue occurs under the following conditions:
The exact reason for this behavior remains unclear. It may vary across different machines or operating systems, likely depending on Vaul's internal implementation details.
Probable root cause...
lies in event bubbling and the fact that both "parent" and "child" drawers are rendered in a portal, making them DOM siblings instead of a parent-child relationship. When clicking a button in the child drawer triggers the event, it bubbles up to the parent drawer's
onClick
handler (if one exists) or a handler managing the parent drawer's state.Now, if we take into account that additional detail about the
onPointerDownOutside callback
, we might see that the core problem arises from how the browser and event listeners interpret removing elements from the DOM during event propagation.Why the Parent Drawer Detects an "Outside Click"
When you click the button in the child drawer, the following sequence occurs:
pointerdown
event is dispatched and captured in the DOM.onclick
callback. While React queues state updates internally (i.e. removal is in fact asynchronous), the process is executed extremely quickly.onPointerDownOutside
check is executed.As a result, the parent drawer interprets the
pointerdown
event as happening outside its boundaries, even though the event originated from inside the child drawer.Key Takeaway
In a nutshell,
onclick
andpointerdown
events.onClick
removes the child drawer synchronously during the event handling phase.onPointerDownOutside
callback checks for "outside clicks," the child drawer no longer exists in the DOM, and the event target (button) appears "outside" the parent drawer.This happens because:
Possible Fixes
Prevent Synchronous Drawer Removal
Defer the removal of the child drawer slightly, allowing the
onPointerDownOutside
check to complete before the DOM changes:This ensures the parent drawer still sees the event as occurring "inside" during the check.
Stop Event Propagation
Use
e.stopPropagation()
in the child drawer's button click handler to prevent the event from being detected by the parent'sonPointerDownOutside
logic:Customize
onPointerDownOutside
We're using libraries (Radix UI, Vaul) that provide the
onPointerDownOutside
callback, hence we can customize the behavior to avoid treating clicks inside the child drawer as "outside" clicks.For example, we can add a condition to the callback:
In Summary
The fixes (like
e.stopPropagation()
orsetTimeout()
) address symptoms, but the root cause is how the bubbling events interact with our drawer closure logic. Ideally, the best solution would be to leverage Vaul's<Drawer.NestedRoot />
API. However, this would require a significant refactoring and restructuring of the modal system. Such a task is time-intensive and best handled by the core team to ensure it does not disrupt the app's core functionality.Another potential solution involves preventing unintended event bubbling at its source. However, since Vaul is an external library, this approach is not feasible for us and I'm not quite sure this is something Vaul should cover.
As a result, this PR aims to highlight the issue while implementing the most straightforward, controlled, and efficient fix: stopping event propagation in child modals' "Cancel" button events. This approach resolves the bug promptly without overhauling the existing structure.
Testing
This fix was tested by: