From d2fcefa89a62e2a5d520a072deb37d817080447f Mon Sep 17 00:00:00 2001 From: Birzool Date: Fri, 6 Nov 2026 14:57:39 +0200 Subject: [PATCH 1/2] [select] Avoid re-rendering scroll arrows on position updates The arrows read SelectPositionerContext, whose value changes with every position update, and subscribed to openMethod. Give them a small context of what they use and subscribe to the computed visibility. --- .../select/positioner/SelectPositioner.tsx | 17 ++- .../positioner/SelectPositionerContext.ts | 26 +++- .../scroll-arrow/SelectScrollArrow.test.tsx | 111 +++++++++++++++++- .../select/scroll-arrow/SelectScrollArrow.tsx | 12 +- packages/react/src/select/store.ts | 4 + 5 files changed, 153 insertions(+), 17 deletions(-) diff --git a/packages/react/src/select/positioner/SelectPositioner.tsx b/packages/react/src/select/positioner/SelectPositioner.tsx index 92df1b4ae78..5636e3dbca2 100644 --- a/packages/react/src/select/positioner/SelectPositioner.tsx +++ b/packages/react/src/select/positioner/SelectPositioner.tsx @@ -12,7 +12,7 @@ import type { Side, UseAnchorPositioningSharedParameters, } from '../../internals/useAnchorPositioning'; -import { SelectPositionerContext } from './SelectPositionerContext'; +import { SelectPositionerContext, SelectScrollArrowContext } from './SelectPositionerContext'; import { InternalBackdrop } from '../../utils/popups/InternalBackdrop'; import { DROPDOWN_COLLISION_AVOIDANCE } from '../../internals/constants'; import { clearStyles } from '../popup/utils'; @@ -212,12 +212,15 @@ export const SelectPositioner = React.forwardRef(function SelectPositioner( side: renderedSide, alignItemWithTriggerActive, setControlledAlignItemWithTrigger, - scrollUpArrowRef, - scrollDownArrowRef, }), [positioning, renderedSide, alignItemWithTriggerActive, setControlledAlignItemWithTrigger], ); + const scrollArrowContextValue: SelectScrollArrowContext = React.useMemo( + () => ({ side: renderedSide, scrollUpArrowRef, scrollDownArrowRef }), + [renderedSide], + ); + return ( - {mounted && modal && } - {element} + + {mounted && modal && ( + + )} + {element} + ); diff --git a/packages/react/src/select/positioner/SelectPositionerContext.ts b/packages/react/src/select/positioner/SelectPositionerContext.ts index 163d6c5c0bb..fc1c745b3d7 100644 --- a/packages/react/src/select/positioner/SelectPositionerContext.ts +++ b/packages/react/src/select/positioner/SelectPositionerContext.ts @@ -6,8 +6,6 @@ export interface SelectPositionerContext extends Omit>; - scrollUpArrowRef: React.RefObject; - scrollDownArrowRef: React.RefObject; } export const SelectPositionerContext = React.createContext( @@ -23,3 +21,27 @@ export function useSelectPositionerContext() { } return context; } + +/** + * What the scroll arrows read from the positioner. Separate from `SelectPositionerContext`, whose + * value changes with every position update, so that a hidden arrow doesn't re-render on those. + */ +export interface SelectScrollArrowContext { + side: 'none' | Side; + scrollUpArrowRef: React.RefObject; + scrollDownArrowRef: React.RefObject; +} + +export const SelectScrollArrowContext = React.createContext( + undefined, +); + +export function useSelectScrollArrowContext() { + const context = React.useContext(SelectScrollArrowContext); + if (!context) { + throw new Error( + 'Base UI: SelectPositionerContext is missing. SelectPositioner parts must be placed within .', + ); + } + return context; +} diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx index 3dcd36392e4..8658948cd41 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx @@ -1,7 +1,7 @@ import { expect, vi, describe, it, beforeEach, afterEach } from 'vitest'; import { Select } from '@base-ui/react/select'; import { act, fireEvent, screen } from '@mui/internal-test-utils'; -import { createRenderer } from '#test-utils'; +import { createRenderer, pressWithTouch } from '#test-utils'; const DEFAULT_ITEM_OFFSETS = [0, 40, 80, 120, 160, 200, 240, 280, 320, 360]; @@ -351,6 +351,115 @@ describe('', () => { expect(downArrow).not.toHaveAttribute('data-visible'); }); + describe('opening and closing', () => { + function pressWithMouse(element: HTMLElement) { + fireEvent.pointerDown(element, { pointerType: 'mouse' }); + fireEvent.mouseDown(element); + } + + async function settle() { + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + } + + /** + * Renders once, so `children` stay the same elements and only the select itself can re-render + * the arrows. Focus keeps the closed popup mounted, so one arrow instance sees the whole cycle. + */ + async function renderClosedSelect(scrollHeight: number) { + const renders = { up: 0, down: 0 }; + let scrollTop = 100; + + await render( + + Open + + + + { + renders.up += 1; + return
; + }} + /> + + stubScroller( + node, + { scrollHeight, clientHeight: 200 }, + () => scrollTop, + (value) => { + scrollTop = value; + }, + ) + } + > + One + + { + renders.down += 1; + return
; + }} + /> + + + + , + ); + + const trigger = screen.getByTestId('trigger'); + fireEvent.focus(trigger); + await settle(); + + return { renders, trigger }; + } + + it.each([ + { name: 'a list that fits, opened with a mouse', scrollHeight: 200, press: pressWithMouse }, + { name: 'a list that overflows, opened by touch', scrollHeight: 400, press: pressWithTouch }, + ])('does not re-render hidden arrows for $name', async ({ scrollHeight, press }) => { + const { renders, trigger } = await renderClosedSelect(scrollHeight); + + expect(renders.up).toBeGreaterThan(0); + expect(renders.down).toBeGreaterThan(0); + + const before = { ...renders }; + + press(trigger); + await settle(); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('down')).not.toHaveAttribute('data-visible'); + expect(renders).toEqual(before); + + fireEvent.keyDown(screen.getByRole('option'), { key: 'Escape' }); + await settle(); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(renders).toEqual(before); + }); + + it.each([ + { name: 'shows the arrows when a mouse opens', press: pressWithMouse, visible: true }, + { name: 'hides the arrows when touch opens', press: pressWithTouch, visible: false }, + ])('$name a list that overflows both ways', async ({ press, visible }) => { + const { trigger } = await renderClosedSelect(400); + + press(trigger); + await settle(); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByTestId('up').hasAttribute('data-visible')).toBe(visible); + expect(screen.getByTestId('down').hasAttribute('data-visible')).toBe(visible); + }); + }); + it('ignores pointer interaction when the arrow has no scrollable popup', async () => { await render( diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx index 5f5febcdd3c..d91620158f3 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx @@ -4,7 +4,7 @@ import { useTimeout } from '@base-ui/utils/useTimeout'; import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import type { BaseUIComponentProps } from '../../internals/types'; import { useSelectRootContext } from '../root/SelectRootContext'; -import { useSelectPositionerContext } from '../positioner/SelectPositionerContext'; +import { useSelectScrollArrowContext } from '../positioner/SelectPositionerContext'; import type { Side } from '../../internals/useAnchorPositioning'; import { useTransitionStatus } from '../../internals/useTransitionStatus'; import type { TransitionStatus } from '../../internals/useTransitionStatus'; @@ -29,15 +29,9 @@ export const SelectScrollArrow = React.forwardRef(function SelectScrollArrow( const isUp = direction === 'up'; const store = useSelectRootContext(); - const { side, scrollDownArrowRef, scrollUpArrowRef } = useSelectPositionerContext(); + const { side, scrollDownArrowRef, scrollUpArrowRef } = useSelectScrollArrowContext(); - const visibleSelector = isUp ? 'scrollUpArrowVisible' : 'scrollDownArrowVisible'; - - const stateVisible = store.useState(visibleSelector); - const openMethod = store.useState('openMethod'); - - // Scroll arrows are disabled for touch modality as they are a hover-only element. - const visible = stateVisible && openMethod !== 'touch'; + const visible = store.useState(isUp ? 'scrollUpArrowShown' : 'scrollDownArrowShown'); const timeout = useTimeout(); diff --git a/packages/react/src/select/store.ts b/packages/react/src/select/store.ts index 52c14106668..28250b99d8a 100644 --- a/packages/react/src/select/store.ts +++ b/packages/react/src/select/store.ts @@ -110,6 +110,10 @@ export const selectors = { scrollUpArrowVisible: (state: State) => state.scrollUpArrowVisible, scrollDownArrowVisible: (state: State) => state.scrollDownArrowVisible, + // Scroll arrows are disabled for touch modality as they are a hover-only element. + scrollUpArrowShown: (state: State) => state.scrollUpArrowVisible && state.openMethod !== 'touch', + scrollDownArrowShown: (state: State) => + state.scrollDownArrowVisible && state.openMethod !== 'touch', hasScrollArrows: (state: State) => state.hasScrollArrows, }; From 94a9bb0ef15301093945ba7a78286f937242cdbf Mon Sep 17 00:00:00 2001 From: Birzool Date: Fri, 6 Nov 2026 16:47:37 +0200 Subject: [PATCH 2/2] [select] Settle the scroll arrow tests on React 18 A mouse press opens the popup in one frame, and the popup reads its scroller in the next. React 18 commits the open only as act exits, so one act never reached that second frame: the mouse test failed, and the touch tests passed without the arrows' visibility being computed. Give the second frame its own act. --- .../select/scroll-arrow/SelectScrollArrow.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx index 8658948cd41..e1b330e4a30 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx @@ -357,12 +357,22 @@ describe('', () => { fireEvent.mouseDown(element); } - async function settle() { + async function advanceFrames() { await act(async () => { await vi.advanceTimersByTimeAsync(100); }); } + /** + * A mouse press opens the popup in one frame, and the popup reads its scroller in the next. + * React 18 commits the open only as `act` exits, once the clock has stopped, so that next frame + * needs an `act` of its own. + */ + async function settle() { + await advanceFrames(); + await advanceFrames(); + } + /** * Renders once, so `children` stay the same elements and only the select itself can re-render * the arrows. Focus keeps the closed popup mounted, so one arrow instance sees the whole cycle.