Skip to content

Fix sidebar collapsing on mobile when clicking portaled elements#13653

Merged
kmcgrady merged 7 commits intodevelopfrom
kmcgrady/popover-sidebar-fix
Jan 27, 2026
Merged

Fix sidebar collapsing on mobile when clicking portaled elements#13653
kmcgrady merged 7 commits intodevelopfrom
kmcgrady/popover-sidebar-fix

Conversation

@sfc-gh-kmcgrady
Copy link
Copy Markdown
Contributor

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

  • Unit Tests: Added 4 comprehensive tests for click-outside behavior on mobile covering desktop/mobile viewports, sidebar clicks, portal clicks, and collapsed state
  • Tests verify the sidebar does not collapse when clicking portaled elements outside the main app container
  • All existing Sidebar tests pass (41 tests total)

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Copilot AI review requested due to automatic review settings January 20, 2026 21:56
@snyk-io
Copy link
Copy Markdown
Contributor

snyk-io bot commented Jan 20, 2026

Snyk checks have passed. No issues have been found so far.

Status Scanner Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 20, 2026

✅ PR preview is ready!

Name Link
📦 Wheel file https://core-previews.s3-us-west-2.amazonaws.com/pr-13653/streamlit-1.53.1-py3-none-any.whl
📦 @streamlit/component-v2-lib Download from artifacts
🕹️ Preview app pr-13653.streamlit.app (☁️ Deploy here if not accessible)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +190 to +192
const mainAppContainer = document.querySelector(
'[data-testid="stApp"]'
)
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +624 to +704
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()
})
})
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@sfc-gh-kmcgrady sfc-gh-kmcgrady added the ai-review If applied to PR or issue will run AI review workflow label Jan 20, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Jan 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This 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 Sidebar.tsx to check if the click target is within the main app container ([data-testid="stApp"]), effectively excluding portaled elements since they render outside this container.

Changes:

  • Sidebar.tsx: Added logic to verify clicks are inside the main app container before collapsing (lines 189-201)
  • Sidebar.test.tsx: Added 4 unit tests for click-outside behavior and mocked window dimensions

Code Quality

The implementation is clean and follows TypeScript best practices:

Strengths:

  • Uses optional chaining (mainAppContainer?.contains(target)) as recommended by the TypeScript guidelines
  • Clear, explanatory comments describing the portal detection logic
  • Added null check for target before checking containment
  • The logic is straightforward and easy to understand

Minor observation (not from this PR):

  • Line 216 in Sidebar.tsx includes lastInnerWidth in the useEffect dependency array, but it's not used in the effect body. This is pre-existing code and not introduced by this PR.

Test Coverage

The PR adds comprehensive unit tests for the click-outside behavior, but there's a gap:

Covered scenarios (negative cases):

  1. Desktop viewport - sidebar should NOT collapse on click outside
  2. Mobile viewport - clicking inside sidebar should NOT collapse it
  3. Mobile viewport - clicking on portaled elements (outside main app) should NOT collapse
  4. Mobile viewport - clicking when already collapsed should NOT collapse

Missing test (positive case):
The tests only verify scenarios where the sidebar should NOT collapse. There's no test verifying that the sidebar DOES collapse when clicking outside the sidebar but inside the main app container on mobile. Per the TypeScript testing guidelines:

"Positive + negative assertions (anti-regression): When you assert that something appears/changes, also add at least one complementary assertion when practical that something else does not appear/change"

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:

  • The ESLint disable comment for fireEvent.mouseDown is appropriate and well-explained
  • The wrapper component correctly includes data-testid="stApp" for the fix to work
  • Proper cleanup in afterEach for portal elements

Backwards Compatibility

The change is backwards compatible:

  • The data-testid="stApp" selector already exists in App.tsx (line 2462)
  • The behavior change is conservative - it prevents unintended collapses rather than adding new collapse triggers
  • Only affects mobile viewport behavior
  • Existing e2e test test_sidebar_no_collapse_on_text_input_mobile continues to pass

Security & Risk

No security concerns identified.

Low regression risk:

  • The fix is additive (additional check before collapse)
  • If mainAppContainer is null for some reason, the optional chaining prevents errors and simply won't trigger collapse
  • The existing test for text input on mobile provides some coverage for the behavior

Recommendations

  1. Add a positive test case that verifies the sidebar DOES collapse when clicking outside the sidebar but inside the main app container on mobile. This ensures the collapse behavior works as expected and prevents accidental regressions.

  2. Consider adding an e2e test for the specific scenario of clicking on portaled elements (like a selectbox dropdown) on mobile to verify the fix works end-to-end. This would provide more confidence since the actual portal behavior is complex.

Verdict

CHANGES 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 opus-4.5-thinking. Please verify the feedback and use your judgment.

@github-actions github-actions bot added the do-not-merge PR is blocked from merging label Jan 20, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Jan 20, 2026

📈 Frontend coverage change detected

The frontend unit test (vitest) coverage has increased by 0.0100%

  • Current PR: 86.2600% (13555 lines, 1862 missed)
  • Latest develop: 86.2500% (13540 lines, 1861 missed)

✅ Coverage change is within normal range.

📊 View detailed coverage comparison

@kmcgrady kmcgrady added ai-review If applied to PR or issue will run AI review workflow and removed do-not-merge PR is blocked from merging labels Jan 22, 2026
@github-actions github-actions bot removed the ai-review If applied to PR or issue will run AI review workflow label Jan 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Summary

This PR fixes issue #13647 where clicking on portaled elements (dropdowns, modals, etc.) on mobile would incorrectly collapse the sidebar. The solution adds a ref (appRootRef) to the main app container and passes it through the context to the Sidebar component. The click-outside handler now checks that the click target is inside the main app container (not just outside the sidebar), effectively excluding clicks on portaled elements since they render outside the app container DOM hierarchy.

Files Changed:

  • frontend/app/src/App.tsx - Adds ref to root app container and passes it through context
  • frontend/app/src/components/Sidebar/Sidebar.tsx - Modifies click-outside logic to exclude portaled elements
  • frontend/app/src/components/StreamlitContextProvider.tsx - Adds appRootRef prop propagation
  • frontend/lib/src/components/core/SidebarConfigContext.tsx - Adds appRootRef to context interface
  • frontend/app/src/components/Sidebar/Sidebar.test.tsx - Adds unit tests for click-outside behavior
  • e2e_playwright/st_sidebar.py - Adds selectbox for portal testing

Code Quality

Strengths:

  • The fix is elegant and uses a clean pattern of ref containment to detect portal clicks
  • Good JSDoc documentation added to SidebarConfigContext.tsx (lines 77-86) explaining the purpose of appRootRef
  • Proper null safety with optional chaining: appRootRef?.current?.contains(target) (line 197 in Sidebar.tsx)
  • The useEffect dependency array correctly includes appRootRef (line 217 in Sidebar.tsx)
  • Test refactoring in Sidebar.test.tsx improves the test setup by manually constructing context providers instead of using renderWithContexts, giving more control over the test environment

Minor Observations:

  • The comment in Sidebar.tsx (lines 190-192) clearly explains the logic, which is helpful for future maintainers
  • The eslint disable comment for testing-library/prefer-user-event (line 640 in test file) is appropriately scoped and justified since fireEvent.mouseDown is specifically needed to test the mousedown event handler

Test Coverage

Unit Tests (Good Coverage):
The PR adds 4 comprehensive unit tests for click-outside behavior in Sidebar.test.tsx:

  1. "does not collapse sidebar when clicking outside on desktop viewport" - Validates desktop behavior
  2. "does not collapse sidebar when clicking inside sidebar" - Validates sidebar click behavior
  3. "does not collapse sidebar when clicking outside the main app container (portaled elements)" - Key test for the fix
  4. "does not collapse when sidebar is already collapsed" - Edge case coverage

The test setup was refactored to properly create an appRootRef that wraps the Sidebar in a div with data-testid="stApp", which accurately simulates the production DOM structure.

E2E Test Gap:
A selectbox was added to e2e_playwright/st_sidebar.py (line 40) with the comment "Selectbox for testing portal behavior on mobile", but no corresponding e2e test was added to st_sidebar_test.py. The existing test_sidebar_no_collapse_on_text_input_mobile tests clicking on an element inside the sidebar, but there's no test that:

  1. Opens a selectbox dropdown on mobile (which renders via portal)
  2. Clicks on a dropdown option (outside the main app container)
  3. Verifies the sidebar remains expanded

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 Compatibility

No breaking changes identified:

  • The appRootRef property is optional in SidebarConfigContextProps (line 86: appRootRef?: RefObject<HTMLDivElement> | null)
  • The default context value in SidebarConfigContext.tsx doesn't include appRootRef, which is correct since it's optional
  • Existing code that doesn't provide appRootRef will continue to work - the sidebar collapse behavior would simply fall back to not checking the container (though in practice appRootRef is always provided by App.tsx)

Security & Risk

No security concerns identified.

Low regression risk:

  • The fix is additive - it adds an additional condition (appRootRef?.current?.contains(target)) to the existing click-outside logic
  • If appRootRef is not available (undefined), the condition appRootRef?.current?.contains(target) evaluates to undefined, which is falsy, so the sidebar won't collapse - this is safe behavior
  • The change only affects mobile viewports (innerWidth <= mediumBreakpointPx)

Recommendations

  1. Add E2E test for portal behavior on mobile (Recommended):
    Add a test to e2e_playwright/st_sidebar_test.py that specifically tests the fixed scenario:

    def test_sidebar_no_collapse_on_selectbox_dropdown_mobile(app: Page):
        app.set_viewport_size({"width": 400, "height": 800})
        
        # Expand the sidebar on mobile
        app.get_by_test_id("stExpandSidebarButton").click()
        
        # Click on the selectbox to open dropdown
        selectbox = app.get_by_test_id("stSidebar").get_by_test_id("stSelectbox")
        selectbox.click()
        
        # Click on a dropdown option (which is portaled outside the app container)
        dropdown_option = app.get_by_text("Option 2")
        dropdown_option.click()
        
        # Verify sidebar is still expanded
        sidebar = app.get_by_test_id("stSidebar")
        expect(sidebar).to_have_attribute("aria-expanded", "true")
  2. Minor - Consider removing unused selectbox if no e2e test is added (Optional):
    If the e2e test is not added, the selectbox added to st_sidebar.py serves no testing purpose and could be removed to keep the test app minimal.

Verdict

APPROVED: 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 opus-4.5-thinking. Please verify the feedback and use your judgment.

kmcgrady and others added 4 commits January 27, 2026 08:23
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]>
@kmcgrady kmcgrady force-pushed the kmcgrady/popover-sidebar-fix branch from 66c344b to fd2bd0b Compare January 27, 2026 16:24
kmcgrady and others added 3 commits January 27, 2026 08:37
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]>
Copy link
Copy Markdown
Collaborator

@mayagbarnes mayagbarnes Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: Did these (AppView.test.tsx and SidebarNav.test.tsx) need to change?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator

@mayagbarnes mayagbarnes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
Curious if the AppView.test.tsx and SidebarNav.test.tsx changes were necessary but non-blocking either way.

@kmcgrady kmcgrady merged commit 94c5a51 into develop Jan 27, 2026
42 checks passed
@kmcgrady kmcgrady deleted the kmcgrady/popover-sidebar-fix branch January 27, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

change:bugfix PR contains bug fix implementation impact:users PR changes affect end users

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Clicking CCv2 managed popovers closes the sidebar on mobile

4 participants