diff --git a/.changeset/mosaic-combobox-option-renders.md b/.changeset/mosaic-combobox-option-renders.md
new file mode 100644
index 00000000000..cf23953207c
--- /dev/null
+++ b/.changeset/mosaic-combobox-option-renders.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+Reduce option re-renders in `Combobox`, `Autocomplete` and `Select`.
diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts
index b74879e0db1..a3b6d7bc804 100644
--- a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts
+++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts
@@ -19,7 +19,6 @@ export interface AutocompleteContextValue {
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
- getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
selectedIndex: number | null;
elementsRef: React.MutableRefObject>;
@@ -30,7 +29,6 @@ export interface AutocompleteContextValue {
setInlineMode: React.Dispatch>;
handleSelect: (value: string, index: number, label: string) => void;
handleInputChange: (value: string) => void;
- registerSelectedIndex: (index: number, value: string) => void;
mounted: boolean;
transitionProps: TransitionProps;
}
@@ -44,3 +42,35 @@ export function useAutocompleteContext() {
}
return ctx;
}
+
+export interface AutocompleteHighlightContextValue {
+ activeIndex: number | null;
+ selectedValue: string | undefined;
+}
+export const AutocompleteHighlightContext = createContext(null);
+
+export function useAutocompleteHighlightContext() {
+ const ctx = useContext(AutocompleteHighlightContext);
+ if (!ctx) {
+ throw new Error('Autocomplete.Option must be used within ');
+ }
+ return ctx;
+}
+
+export interface AutocompleteOptionsContextValue {
+ getItemProps: UseInteractionsReturn['getItemProps'];
+ handleSelect: (value: string, index: number, label: string) => void;
+ refs: ExtendedRefs;
+ valuesByIndexRef: React.MutableRefObject
}>{loadingMore ? : null}
+ Loading}>
+ {isPending || value !== deferredValue ? 'Pending' : 'Idle'}
+ (update === 'deferred' ? setValue(next) : startTransition(() => setValue(next)))}
+ >
+
+
+
+ {fruits.map(option => (
+ {
+ onRenderOption?.({ option: option.value, parentValue, selected: props['aria-selected'] });
+ const active = 'data-active' in props;
+ onRenderHover?.({ option: option.value, hovered, active });
+ return (
+
+ startTransition(() => {
+ setHovered(option.value);
+ props.onMouseMove?.(event);
+ })
+ : props.onMouseMove
+ }
+ >
+ {props.children}
+ {suspendSelected && props['aria-selected'] ? : null}
+ {active && suspendWhenActive === option.value ? : null}
+
+ );
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+ >
+ );
+ }
+
+ describe.each(['transition', 'deferred'] as const)('when the parent updates the value with %s', update => {
+ it('renders each option with the selection its parent passed down', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders).not.toContainEqual({ option: 'banana', parentValue: 'banana', selected: false });
+ });
+
+ it('renders the new selection in the same render as its parent, with no second pass', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders.filter(entry => entry.option === 'banana' && entry.parentValue === 'banana')).toEqual([
+ { option: 'banana', parentValue: 'banana', selected: true },
+ ]);
+ });
+
+ it('keeps the current options on screen while a selection suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false');
+ });
+ });
+
+ it('moves the highlight with arrow keys while an unrelated transition is pending', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByText('Load more'));
+ await waitFor(() => expect(screen.getByText('Loading more')).toBeInTheDocument());
+
+ await user.click(screen.getByPlaceholderText('Search fruits...'));
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowDown}');
+
+ const banana = screen.getByRole('option', { name: 'Banana' });
+ expect(banana).toHaveAttribute('data-active');
+ expect(screen.getByPlaceholderText('Search fruits...')).toHaveAttribute('aria-activedescendant', banana.id);
+ expect(screen.getByText('Loading more')).toBeInTheDocument();
+ });
+
+ it('renders the hovered option as active in the same render as a hover transition', async () => {
+ const user = userEvent.setup();
+ const renders: HoverRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'));
+ expect(renders).not.toContainEqual({ option: 'banana', hovered: 'banana', active: false });
+ });
+
+ it('keeps the current options on screen while a hover transition suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).not.toHaveAttribute('data-active');
+ });
+});
diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts
index fa1be72ca63..f051b556a82 100644
--- a/packages/mosaic/src/primitives/combobox/combobox-context.ts
+++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts
@@ -20,7 +20,6 @@ export interface ComboboxContextValue {
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
- getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
selectedIndex: number | null;
elementsRef: React.MutableRefObject>;
@@ -35,8 +34,6 @@ export interface ComboboxContextValue {
setOpen: (open: boolean) => void;
focusInput: () => void;
popupId: string | undefined;
- registerSelectedIndex: (index: number, value: string, label: string) => (() => void) | undefined;
- registerOption: (index: number) => void;
mounted: boolean;
transitionProps: TransitionProps;
}
@@ -50,3 +47,36 @@ export function useComboboxContext() {
}
return ctx;
}
+
+export interface ComboboxOptionsContextValue {
+ getItemProps: UseInteractionsReturn['getItemProps'];
+ handleSelect: (value: string, index: number, label: string) => void;
+ refs: ExtendedRefs;
+ valuesByIndexRef: React.MutableRefObject>;
+ registerSelectedIndex: (index: number, value: string, label: string, selected: boolean) => (() => void) | undefined;
+ registerOption: (index: number) => void;
+}
+
+export interface ComboboxHighlightContextValue {
+ activeIndex: number | null;
+ selectedValue: string | null;
+}
+export const ComboboxHighlightContext = createContext(null);
+
+export function useComboboxHighlightContext() {
+ const ctx = useContext(ComboboxHighlightContext);
+ if (!ctx) {
+ throw new Error('Combobox.Option must be used within ');
+ }
+ return ctx;
+}
+
+export const ComboboxOptionsContext = createContext(null);
+
+export function useComboboxOptionsContext() {
+ const ctx = useContext(ComboboxOptionsContext);
+ if (!ctx) {
+ throw new Error('Combobox.Option must be used within ');
+ }
+ return ctx;
+}
diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx
index 96191114995..9dd1c19d31c 100644
--- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx
+++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx
@@ -4,7 +4,7 @@ import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';
import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender } from '../utils';
-import { useComboboxContext } from './combobox-context';
+import { useComboboxHighlightContext, useComboboxOptionsContext } from './combobox-context';
import { ComboboxOptionContext } from './combobox-option-context';
export interface ComboboxOptionProps extends ComponentProps<'div'> {
@@ -15,83 +15,95 @@ export interface ComboboxOptionProps extends ComponentProps<'div'> {
export const ComboboxOption = React.forwardRef(
function ComboboxOption(props, ref) {
- const { render, value, label, disabled, ...otherProps } = props;
- const {
- activeIndex,
- selectedValue,
- getItemProps,
- handleSelect,
- valuesByIndexRef,
- registerSelectedIndex,
- registerOption,
- refs,
- } = useComboboxContext();
+ const { activeIndex, selectedValue } = useComboboxHighlightContext();
+ const { ref: itemRef, index } = useListItem({ label: props.label ?? props.value });
+ return (
+
+ );
+ },
+);
- const id = useId();
- const displayLabel = label ?? value;
- const { ref: itemRef, index } = useListItem({ label: displayLabel });
+interface ComboboxOptionBodyProps extends ComboboxOptionProps {
+ forwardedRef: React.ForwardedRef;
+ listItemRef: (node: HTMLElement | null) => void;
+ index: number;
+ isActive: boolean;
+ isSelected: boolean;
+}
- const isSelected = selectedValue === value;
- const isActive = activeIndex === index;
+const ComboboxOptionBody = React.memo(function ComboboxOptionBody(bodyProps: ComboboxOptionBodyProps) {
+ const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, ...props } = bodyProps;
+ const { render, value, label, disabled, ...otherProps } = props;
+ const { getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, registerOption, refs } =
+ useComboboxOptionsContext();
- useEffect(() => {
- const map = valuesByIndexRef.current;
- if (!disabled) {
- map.set(index, value);
- }
- const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel);
- registerOption(index);
- return () => {
- map.delete(index);
- unregisterSelectedIndex?.();
- };
- }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex, registerOption]);
+ const id = useId();
+ const displayLabel = label ?? value;
- const state = {
- selected: isSelected,
- active: isActive,
- disabled: !!disabled,
+ useEffect(() => {
+ const map = valuesByIndexRef.current;
+ if (!disabled) {
+ map.set(index, value);
+ }
+ const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel, isSelected);
+ registerOption(index);
+ return () => {
+ map.delete(index);
+ unregisterSelectedIndex?.();
};
+ }, [index, value, displayLabel, disabled, isSelected, valuesByIndexRef, registerSelectedIndex, registerOption]);
- const ownProps = {
- id,
- role: 'option',
- 'aria-selected': isSelected,
- 'aria-disabled': disabled || undefined,
- } satisfies DefaultProps<'div'>;
+ const state = {
+ selected: isSelected,
+ active: isActive,
+ disabled: !!disabled,
+ };
- const defaultProps = {
- ...ownProps,
- ...getItemProps({
- onClick() {
- if (!disabled) {
- handleSelect(value, index, displayLabel);
- const reference = refs.domReference.current;
- if (isHTMLElement(reference)) {
- reference.focus();
- }
+ const ownProps = {
+ id,
+ role: 'option',
+ 'aria-selected': isSelected,
+ 'aria-disabled': disabled || undefined,
+ } satisfies DefaultProps<'div'>;
+
+ const defaultProps = {
+ ...ownProps,
+ ...getItemProps({
+ onClick() {
+ if (!disabled) {
+ handleSelect(value, index, displayLabel);
+ const reference = refs.domReference.current;
+ if (isHTMLElement(reference)) {
+ reference.focus();
}
- },
- }),
- };
+ }
+ },
+ }),
+ };
- const merged = mergeProps<'div'>(defaultProps, otherProps);
- // The option id is owned by the primitive and drives the input's
- // aria-activedescendant linkage: a consumer-supplied id must not override it.
- merged.id = id;
+ const merged = mergeProps<'div'>(defaultProps, otherProps);
+ // The option id is owned by the primitive and drives the input's
+ // aria-activedescendant linkage: a consumer-supplied id must not override it.
+ merged.id = id;
- const element = useRender({
- defaultTagName: 'div',
- render,
- ref: [itemRef, ref],
- state,
- stateAttributesMapping: {
- selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
- active: (v: boolean) => (v ? { 'data-active': '' } : null),
- disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
- },
- props: merged,
- });
- return {element};
- },
-);
+ const element = useRender({
+ defaultTagName: 'div',
+ render,
+ ref: [itemRef, ref],
+ state,
+ stateAttributesMapping: {
+ selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
+ active: (v: boolean) => (v ? { 'data-active': '' } : null),
+ disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
+ },
+ props: merged,
+ });
+ return {element};
+});
diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx
index 60f70ddec0e..7c6a01620b5 100644
--- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx
+++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx
@@ -22,7 +22,14 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import { useControllableState } from '../hooks/use-controllable-state';
import { useTransition } from '../hooks/use-transition';
import { cssVars } from '../utils/css-vars';
-import { ComboboxContext, type ComboboxContextValue } from './combobox-context';
+import {
+ ComboboxContext,
+ type ComboboxContextValue,
+ ComboboxHighlightContext,
+ type ComboboxHighlightContextValue,
+ ComboboxOptionsContext,
+ type ComboboxOptionsContextValue,
+} from './combobox-context';
export interface ComboboxProps {
/** Controlled input text. */
@@ -115,17 +122,14 @@ function ComboboxInner(props: ComboboxProps) {
setActiveIndex(0);
}
}, []);
- const registerSelectedIndex = useCallback(
- (index: number, value: string, label: string) => {
- labelsByValueRef.current.set(value, label);
- if (value === selectedValue) {
- setSelectedIndex(index);
- return () => setSelectedIndex(current => (current === index ? null : current));
- }
+ const registerSelectedIndex = useCallback((index: number, value: string, label: string, selected: boolean) => {
+ labelsByValueRef.current.set(value, label);
+ if (!selected) {
return undefined;
- },
- [selectedValue],
- );
+ }
+ setSelectedIndex(index);
+ return () => setSelectedIndex(current => (current === index ? null : current));
+ }, []);
const previousOpenRef = useRef(open);
useEffect(() => {
@@ -251,7 +255,6 @@ function ComboboxInner(props: ComboboxProps) {
placement,
getReferenceProps,
getFloatingProps,
- getItemProps,
activeIndex,
selectedIndex,
elementsRef,
@@ -266,8 +269,6 @@ function ComboboxInner(props: ComboboxProps) {
setOpen,
focusInput,
popupId,
- registerSelectedIndex,
- registerOption,
mounted,
transitionProps,
}),
@@ -282,7 +283,6 @@ function ComboboxInner(props: ComboboxProps) {
placement,
getReferenceProps,
getFloatingProps,
- getItemProps,
activeIndex,
selectedIndex,
handleSelect,
@@ -290,16 +290,37 @@ function ComboboxInner(props: ComboboxProps) {
setOpen,
focusInput,
popupId,
- registerSelectedIndex,
- registerOption,
mounted,
transitionProps,
],
);
+ const optionsContextValue = useMemo(
+ () => ({
+ getItemProps,
+ handleSelect,
+ refs,
+ valuesByIndexRef,
+ registerSelectedIndex,
+ registerOption,
+ }),
+ [getItemProps, handleSelect, refs, registerSelectedIndex, registerOption],
+ );
+
+ const highlightContextValue = useMemo(
+ () => ({ activeIndex, selectedValue }),
+ [activeIndex, selectedValue],
+ );
+
return (
- {children}
+
+
+
+ {children}
+
+
+
);
}
diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx
index e728e8d38b4..d57bedd001b 100644
--- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx
+++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx
@@ -1,6 +1,6 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { createRef, StrictMode, useState } from 'react';
+import { createRef, StrictMode, Suspense, useDeferredValue, useState, useTransition } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Combobox, type ComboboxTriggerProps } from './index';
@@ -612,3 +612,252 @@ describe('Combobox', () => {
});
});
});
+
+describe('Combobox option renders', () => {
+ it('re-renders only the options whose highlight changes', async () => {
+ const user = userEvent.setup();
+ const renders: string[] = [];
+ function Fixture({ value }: { value: string }) {
+ return (
+
+
+
+
+
+ {fruits.map(fruit => (
+ {
+ renders.push(fruit.value);
+ return ;
+ }}
+ >
+ {fruit.label}
+
+ ))}
+
+
+
+ );
+ }
+ const { rerender } = render();
+ await user.click(screen.getByRole('button', { name: 'Show fruits' }));
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active');
+
+ renders.length = 0;
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active');
+ expect(screen.getByRole('combobox')).toHaveAttribute(
+ 'aria-activedescendant',
+ screen.getByRole('option', { name: 'Cherry' }).id,
+ );
+ expect(new Set(renders)).toEqual(new Set(['banana', 'cherry']));
+
+ renders.length = 0;
+ await user.hover(screen.getByRole('option', { name: 'Apple' }));
+ expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('data-active');
+ expect(new Set(renders)).toEqual(new Set(['cherry', 'apple']));
+
+ rerender();
+ expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false');
+ });
+});
+
+describe('Combobox selection in a transition', () => {
+ const neverResolves = new Promise(() => {});
+ function Suspends(): never {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol; `React.use()` needs React 19.
+ throw neverResolves;
+ }
+
+ type HoverRender = { option: string; hovered: string | null; active: boolean };
+ type OptionRender = { option: string; parentValue: string | null; selected: unknown };
+
+ function Fixture({
+ update = 'transition',
+ onRenderOption,
+ suspendSelected = false,
+ hoverInTransition = false,
+ onRenderHover,
+ suspendWhenActive,
+ }: {
+ update?: 'transition' | 'deferred';
+ onRenderOption?: (render: OptionRender) => void;
+ suspendSelected?: boolean;
+ hoverInTransition?: boolean;
+ onRenderHover?: (render: HoverRender) => void;
+ suspendWhenActive?: string;
+ }) {
+ const [value, setValue] = useState(null);
+ const deferredValue = useDeferredValue(value);
+ const [isPending, startTransition] = useTransition();
+ const [hovered, setHovered] = useState(null);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [isLoadingMore, startLoadingMore] = useTransition();
+ const parentValue = update === 'deferred' ? deferredValue : value;
+ return (
+ <>
+
+ {isLoadingMore ? 'Loading more' : 'Loaded'}
+ Loading more fallback}>{loadingMore ? : null}
+ Loading}>
+ {isPending || value !== deferredValue ? 'Pending' : 'Idle'}
+ (update === 'deferred' ? setValue(next) : startTransition(() => setValue(next)))}
+ >
+
+
+ {fruits.map(option => (
+ {
+ onRenderOption?.({ option: option.value, parentValue, selected: props['aria-selected'] });
+ const active = 'data-active' in props;
+ onRenderHover?.({ option: option.value, hovered, active });
+ return (
+
+ startTransition(() => {
+ setHovered(option.value);
+ props.onMouseMove?.(event);
+ })
+ : props.onMouseMove
+ }
+ >
+ {props.children}
+ {suspendSelected && props['aria-selected'] ? : null}
+ {active && suspendWhenActive === option.value ? : null}
+
+ );
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+
+ >
+ );
+ }
+
+ describe.each(['transition', 'deferred'] as const)('when the parent updates the value with %s', update => {
+ it('renders each option with the selection its parent passed down', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders).not.toContainEqual({ option: 'banana', parentValue: 'banana', selected: false });
+ });
+
+ it('renders the new selection in the same render as its parent, with no second pass', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders.filter(entry => entry.option === 'banana' && entry.parentValue === 'banana')).toEqual([
+ { option: 'banana', parentValue: 'banana', selected: true },
+ ]);
+ });
+
+ it('keeps the current options on screen while a selection suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false');
+ });
+ });
+
+ it('moves the highlight with arrow keys while an unrelated transition is pending', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Load more' }));
+ await waitFor(() => expect(screen.getByText('Loading more')).toBeInTheDocument());
+
+ await user.click(screen.getByRole('combobox'));
+ await user.keyboard('{ArrowDown}');
+ await user.keyboard('{ArrowDown}');
+
+ const banana = screen.getByRole('option', { name: 'Banana' });
+ expect(banana).toHaveAttribute('data-active');
+ expect(screen.getByRole('combobox')).toHaveAttribute('aria-activedescendant', banana.id);
+ expect(screen.getByText('Loading more')).toBeInTheDocument();
+ });
+
+ it('renders the hovered option as active in the same render as a hover transition', async () => {
+ const user = userEvent.setup();
+ const renders: HoverRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'));
+ expect(renders).not.toContainEqual({ option: 'banana', hovered: 'banana', active: false });
+ });
+
+ it('keeps the current options on screen while a hover transition suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).not.toHaveAttribute('data-active');
+ });
+});
diff --git a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx
index 99c8fee0916..644592a3b6d 100644
--- a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx
+++ b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx
@@ -1,6 +1,6 @@
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { createRef } from 'react';
+import { createRef, Suspense, useDeferredValue, useState, useTransition } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { axe } from '../../test-utils/axe';
@@ -1077,3 +1077,302 @@ describe('Select.Group', () => {
expect(() => render(Fruits)).toThrow(/within /);
});
});
+
+describe('Select option renders', () => {
+ it('re-renders only the options whose highlight changes in a multiple select', async () => {
+ const user = userEvent.setup();
+ const renders: string[] = [];
+ const options = [...fruits, { label: 'Date', value: 'date' }];
+ render(
+
+
+
+
+
+
+ {options.map(({ label, value }) => (
+ {
+ renders.push(value);
+ return (
+
+ );
+ }}
+ >
+ {label}
+
+ ))}
+
+
+ ,
+ );
+ await user.click(screen.getByRole('combobox'));
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'));
+
+ renders.length = 0;
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active');
+ expect(new Set(renders)).toEqual(new Set(['banana', 'cherry']));
+
+ await user.keyboard('{Enter}');
+ expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('re-renders only the options whose highlight changes in a controlled single select', async () => {
+ const user = userEvent.setup();
+ const renders: string[] = [];
+ render(
+ {}}
+ >
+
+
+
+
+
+ {fruits.map(({ label, value }) => (
+ {
+ renders.push(value);
+ return (
+
+ );
+ }}
+ >
+ {label}
+
+ ))}
+
+
+ ,
+ );
+ await user.click(screen.getByRole('combobox'));
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'));
+
+ renders.length = 0;
+ await user.keyboard('{ArrowDown}');
+ expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('data-active');
+ expect(new Set(renders)).toEqual(new Set(['banana', 'cherry']));
+ });
+});
+
+describe('Select selection in a transition', () => {
+ const neverResolves = new Promise(() => {});
+ function Suspends(): never {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol; `React.use()` needs React 19.
+ throw neverResolves;
+ }
+
+ type HoverRender = { option: string; hovered: string | null; active: boolean };
+ type OptionRender = { option: string; parentValue: string; selected: unknown };
+
+ function Fixture({
+ update = 'transition',
+ onRenderOption,
+ suspendSelected = false,
+ hoverInTransition = false,
+ onRenderHover,
+ suspendWhenActive,
+ }: {
+ update?: 'transition' | 'deferred';
+ onRenderOption?: (render: OptionRender) => void;
+ suspendSelected?: boolean;
+ hoverInTransition?: boolean;
+ onRenderHover?: (render: HoverRender) => void;
+ suspendWhenActive?: string;
+ }) {
+ const [value, setValue] = useState('');
+ const deferredValue = useDeferredValue(value);
+ const [isPending, startTransition] = useTransition();
+ const [hovered, setHovered] = useState(null);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [isLoadingMore, startLoadingMore] = useTransition();
+ const parentValue = update === 'deferred' ? deferredValue : value;
+ return (
+ <>
+
+ {isLoadingMore ? 'Loading more' : 'Loaded'}
+ Loading more fallback}>{loadingMore ? : null}
+ Loading}>
+ {isPending || value !== deferredValue ? 'Pending' : 'Idle'}
+ (update === 'deferred' ? setValue(next) : startTransition(() => setValue(next)))}
+ >
+
+
+
+
+
+ {fruits.map(option => (
+ {
+ onRenderOption?.({ option: option.value, parentValue, selected: props['aria-selected'] });
+ const active = 'data-active' in props;
+ onRenderHover?.({ option: option.value, hovered, active });
+ return (
+
+ );
+ }}
+ >
+ {option.label}
+
+ ))}
+
+
+
+
+ >
+ );
+ }
+
+ describe.each(['transition', 'deferred'] as const)('when the parent updates the value with %s', update => {
+ it('renders each option with the selection its parent passed down', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders).not.toContainEqual({ option: 'banana', parentValue: 'banana', selected: false });
+ });
+
+ it('renders the new selection in the same render as its parent, with no second pass', async () => {
+ const user = userEvent.setup();
+ const renders: OptionRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'),
+ );
+ expect(renders.filter(entry => entry.option === 'banana' && entry.parentValue === 'banana')).toEqual([
+ { option: 'banana', parentValue: 'banana', selected: true },
+ ]);
+ });
+
+ it('keeps the current options on screen while a selection suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.click(await screen.findByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'false');
+ });
+ });
+
+ it('moves the highlight with arrow keys while an unrelated transition is pending', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Load more' }));
+ await waitFor(() => expect(screen.getByText('Loading more')).toBeInTheDocument());
+
+ screen.getByRole('option', { name: 'Apple' }).focus();
+ await user.keyboard('{ArrowDown}');
+
+ expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active');
+ expect(screen.getByText('Loading more')).toBeInTheDocument();
+ });
+
+ it('renders the hovered option as active in the same render as a hover transition', async () => {
+ const user = userEvent.setup();
+ const renders: HoverRender[] = [];
+ render(
+ renders.push(entry)}
+ />,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'));
+ expect(renders).not.toContainEqual({ option: 'banana', hovered: 'banana', active: false });
+ });
+
+ it('keeps the current options on screen while a hover transition suspends', async () => {
+ const user = userEvent.setup();
+ render(
+ ,
+ );
+
+ await user.hover(screen.getByRole('option', { name: 'Banana' }));
+
+ await waitFor(() => expect(screen.getByText('Pending')).toBeInTheDocument());
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'Banana' })).not.toHaveAttribute('data-active');
+ });
+});
diff --git a/packages/mosaic/src/primitives/select/select-context.ts b/packages/mosaic/src/primitives/select/select-context.ts
index 6689dd02c32..6f4f480f392 100644
--- a/packages/mosaic/src/primitives/select/select-context.ts
+++ b/packages/mosaic/src/primitives/select/select-context.ts
@@ -23,7 +23,6 @@ export interface SelectContextValue {
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
- getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
setActiveIndex: React.Dispatch>;
selectedIndex: number | null;
@@ -59,3 +58,35 @@ export function useSelectContext() {
}
return ctx;
}
+
+export interface SelectHighlightContextValue {
+ activeIndex: number | null;
+ selectedValues: string[];
+}
+export const SelectHighlightContext = createContext(null);
+
+export function useSelectHighlightContext() {
+ const ctx = useContext(SelectHighlightContext);
+ if (!ctx) {
+ throw new Error('Select.Option must be used within