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..e1b330e4a30 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,125 @@ 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 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.
+ */
+ 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,
};