Fix sidebar collapsing on mobile when clicking portaled elements#13653
Fix sidebar collapsing on mobile when clicking portaled elements#13653
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ PR preview is ready!
|
There was a problem hiding this comment.
Pull request overview
This PR fixes a bug where the sidebar would incorrectly collapse on mobile when users clicked on portaled elements (dropdowns, modals, etc.). The fix adds a check to ensure clicks are within the main app container before collapsing the sidebar, since portals render outside this container.
Changes:
- Modified click-outside logic to check if the click target is within the main app container
- Added comprehensive unit tests for mobile click-outside behavior covering desktop/mobile viewports, sidebar clicks, portal clicks, and collapsed state
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| frontend/app/src/components/Sidebar/Sidebar.tsx | Added portal detection by checking if click target is within main app container before collapsing sidebar |
| frontend/app/src/components/Sidebar/Sidebar.test.tsx | Added mock for window dimensions, wrapped sidebar in stApp container, and added 4 new tests for click-outside behavior |
| const mainAppContainer = document.querySelector( | ||
| '[data-testid="stApp"]' | ||
| ) |
There was a problem hiding this comment.
Calling document.querySelector on every mousedown event can impact performance. The main app container element doesn't change during the component's lifecycle, so this lookup should be done once and cached. Consider moving the querySelector call outside the event handler, either as a ref or by querying it once when the effect is set up and storing it in a variable within the effect scope.
| describe("Click Outside Behavior on Mobile", () => { | ||
| beforeEach(() => { | ||
| // Set mobile viewport (less than theme.breakpoints.md which is typically 768px) | ||
| mockWindowDimensions.innerWidth = 500 | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| // Clean up any elements appended to body | ||
| document | ||
| .querySelectorAll("[data-testid='mock-portal']") | ||
| .forEach(el => el.remove()) | ||
| // Restore desktop viewport | ||
| mockWindowDimensions.innerWidth = 1024 | ||
| }) | ||
|
|
||
| it("does not collapse sidebar when clicking outside on desktop viewport", () => { | ||
| // Temporarily set to desktop viewport | ||
| mockWindowDimensions.innerWidth = 1024 | ||
|
|
||
| const mockOnToggleCollapse = vi.fn() | ||
|
|
||
| renderSidebar({ | ||
| isCollapsed: false, | ||
| onToggleCollapse: mockOnToggleCollapse, | ||
| }) | ||
|
|
||
| // Click on main app area - should NOT collapse on desktop | ||
| const mainApp = screen.getByTestId("stApp") | ||
| fireEvent.mouseDown(mainApp) | ||
|
|
||
| expect(mockOnToggleCollapse).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("does not collapse sidebar when clicking inside sidebar", () => { | ||
| const mockOnToggleCollapse = vi.fn() | ||
|
|
||
| renderSidebar({ | ||
| isCollapsed: false, | ||
| onToggleCollapse: mockOnToggleCollapse, | ||
| }) | ||
|
|
||
| // Click inside sidebar | ||
| fireEvent.mouseDown(screen.getByTestId("stSidebarContent")) | ||
|
|
||
| expect(mockOnToggleCollapse).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("does not collapse sidebar when clicking outside the main app container (portaled elements)", () => { | ||
| const mockOnToggleCollapse = vi.fn() | ||
|
|
||
| renderSidebar({ | ||
| isCollapsed: false, | ||
| onToggleCollapse: mockOnToggleCollapse, | ||
| }) | ||
|
|
||
| // Create an element outside the main app container (simulating a portal) | ||
| const portalElement = document.createElement("div") | ||
| portalElement.setAttribute("data-testid", "mock-portal") | ||
| document.body.appendChild(portalElement) | ||
|
|
||
| // Click on element outside the main app container - should NOT collapse | ||
| fireEvent.mouseDown(portalElement) | ||
|
|
||
| expect(mockOnToggleCollapse).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("does not collapse when sidebar is already collapsed", () => { | ||
| const mockOnToggleCollapse = vi.fn() | ||
|
|
||
| renderSidebar({ | ||
| isCollapsed: true, | ||
| onToggleCollapse: mockOnToggleCollapse, | ||
| }) | ||
|
|
||
| // Click on main app area | ||
| const mainApp = screen.getByTestId("stApp") | ||
| fireEvent.mouseDown(mainApp) | ||
|
|
||
| expect(mockOnToggleCollapse).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
There was a problem hiding this comment.
The test suite is missing a positive test case that verifies the sidebar actually collapses when clicking on the main app area (outside the sidebar) on mobile viewport. Currently, only negative cases are tested (when it should NOT collapse). Add a test that clicks on an element within the main app container but outside the sidebar on mobile and asserts that onToggleCollapse is called with true.
SummaryThis PR fixes an issue where the sidebar would incorrectly collapse on mobile when users click on portaled elements (dropdowns, modals, popovers, etc.) that render outside the normal DOM hierarchy. The fix modifies the click-outside handler in Changes:
Code QualityThe implementation is clean and follows TypeScript best practices: Strengths:
Minor observation (not from this PR):
Test CoverageThe PR adds comprehensive unit tests for the click-outside behavior, but there's a gap: Covered scenarios (negative cases):
Missing test (positive case):
The inverse also applies - testing the positive case ensures the collapse behavior isn't accidentally broken by future changes. Recommendation: Add a test like: it("collapses sidebar when clicking outside sidebar but inside main app on mobile", () => {
mockWindowDimensions.innerWidth = 500 // Mobile
const mockOnToggleCollapse = vi.fn()
renderSidebar({
isCollapsed: false,
onToggleCollapse: mockOnToggleCollapse,
})
// Click outside sidebar but inside main app area
const mainApp = screen.getByTestId("stApp")
fireEvent.mouseDown(mainApp)
expect(mockOnToggleCollapse).toHaveBeenCalledWith(true)
})Other test quality notes:
Backwards CompatibilityThe change is backwards compatible:
Security & RiskNo security concerns identified. Low regression risk:
Recommendations
VerdictCHANGES REQUESTED: The fix is well-implemented and addresses the reported issue correctly. However, adding a positive test case that verifies the collapse behavior still works when expected would strengthen the test coverage and align with the testing best practices. This is a minor addition that should be quick to implement. This is an automated AI review using |
📈 Frontend coverage change detectedThe frontend unit test (vitest) coverage has increased by 0.0100%
✅ Coverage change is within normal range. |
SummaryThis PR fixes issue #13647 where clicking on portaled elements (dropdowns, modals, etc.) on mobile would incorrectly collapse the sidebar. The solution adds a ref ( Files Changed:
Code QualityStrengths:
Minor Observations:
Test CoverageUnit Tests (Good Coverage):
The test setup was refactored to properly create an E2E Test Gap:
As noted in the unit test comments (lines 688-691), the positive case (sidebar DOES collapse when clicking outside on mobile) is meant to be tested via e2e, but the portal-specific scenario also deserves e2e coverage. Backwards CompatibilityNo breaking changes identified:
Security & RiskNo security concerns identified. Low regression risk:
Recommendations
VerdictAPPROVED: This is a well-implemented fix for a real user-facing bug. The solution is elegant, properly documented, and has good unit test coverage. The code follows Streamlit's existing patterns and maintains backwards compatibility. The only gap is the missing e2e test for the portal scenario, but this is a minor issue that can be addressed separately, and the unit tests adequately cover the critical behavior. The fix is low-risk and ready for merge. This is an automated AI review using |
When clicking on portaled elements (dropdowns, modals, etc.) on mobile, the sidebar would incorrectly collapse because portals are rendered outside the normal DOM hierarchy. This fix simplifies the detection by checking if the click target is within the main app container. Since portals render outside the app container, they are naturally excluded from triggering a collapse. Fixes: #13647 Co-Authored-By: Claude <[email protected]>
Instead of creating a new AppContext for the app root ref, integrate it into the existing SidebarConfigContext. This simplifies the context hierarchy and keeps sidebar-related configuration in one place. Changes: - Add appRootRef property to SidebarConfigContextProps - Update StreamlitContextProvider to accept and pass appRootRef - Update App.tsx to pass appRootRef through StreamlitContextProvider - Update Sidebar.tsx to read appRootRef from SidebarConfigContext - Update tests to provide appRootRef via SidebarConfigContext - Add selectbox to e2e test file for portal behavior testing Co-Authored-By: Claude <[email protected]>
66c344b to
fd2bd0b
Compare
Update test helper functions to use the new RenderWithContextsOptions type where appRootRef is a boolean instead of a RefObject. Also fix the rerenderWithContexts function to filter out appRootRef when merging options. Co-Authored-By: Claude <[email protected]>
- Remove unused SidebarConfigContextProps imports from test files - Memoize sidebarConfigValue in renderWithContexts to avoid recreating context object on every render - Use explicit click coordinates in sidebar collapse e2e test to avoid flakiness from clicking on sidebar overlay Co-Authored-By: Claude <[email protected]>
The useMemo was causing rerenderWithContexts to not update the context value when sidebarConfigContext changed, because the memoization only depended on appRootRef which doesn't change. Instead, use eslint-disable comment to suppress the lint warning since this is test code where we need the value to update on each rerender. Co-Authored-By: Claude <[email protected]>
There was a problem hiding this comment.
Question: Did these (AppView.test.tsx and SidebarNav.test.tsx) need to change?
There was a problem hiding this comment.
The problem is the type mismatch between:
SidebarConfigContextProps.appRootRef: RefObject | undefined
RenderWithContextsOptions["sidebarConfigContext"].appRootRef: boolean | undefined
So this was just meant to address the type changes.
mayagbarnes
left a comment
There was a problem hiding this comment.
LGTM!
Curious if the AppView.test.tsx and SidebarNav.test.tsx changes were necessary but non-blocking either way.
Describe your changes
When clicking on portaled elements (dropdowns, modals, etc.) on mobile, the sidebar would incorrectly collapse because portals are rendered outside the normal DOM hierarchy. This fix simplifies portal detection by checking if the click target is within the main app container. Since portals render outside the app container, they are naturally excluded from triggering a collapse.
GitHub Issue Link (if applicable)
Fixes #13647
Testing Plan
Contribution License Agreement
By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.