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>; + registerSelectedIndex: (index: number, selected: boolean) => void; +} + +export const AutocompleteOptionsContext = createContext(null); + +export function useAutocompleteOptionsContext() { + const ctx = useContext(AutocompleteOptionsContext); + if (!ctx) { + throw new Error('Autocomplete.Option must be used within '); + } + return ctx; +} diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx index 2fb1977d24a..76d1b2cf3d9 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-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 { useAutocompleteContext } from './autocomplete-context'; +import { useAutocompleteHighlightContext, useAutocompleteOptionsContext } from './autocomplete-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { value: string; @@ -14,72 +14,91 @@ export interface AutocompleteOptionProps extends ComponentProps<'div'> { export const AutocompleteOption = React.forwardRef( function AutocompleteOption(props, ref) { - const { render, value, label, disabled, ...otherProps } = props; - const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = - useAutocompleteContext(); + const { activeIndex, selectedValue } = useAutocompleteHighlightContext(); + 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 AutocompleteOptionBodyProps extends AutocompleteOptionProps { + forwardedRef: React.ForwardedRef; + listItemRef: (node: HTMLElement | null) => void; + index: number; + isActive: boolean; + isSelected: boolean; +} - const isSelected = selectedValue === value; - const isActive = activeIndex === index; +const AutocompleteOptionBody = React.memo(function AutocompleteOptionBody(bodyProps: AutocompleteOptionBodyProps) { + const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, ...props } = bodyProps; + const { render, value, label, disabled, ...otherProps } = props; + const { getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = useAutocompleteOptionsContext(); - useEffect(() => { - const map = valuesByIndexRef.current; - if (!disabled) { - map.set(index, value); - } - registerSelectedIndex(index, value); - return () => { - map.delete(index); - }; - }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); + 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); + } + registerSelectedIndex(index, isSelected); + return () => { + map.delete(index); }; + }, [index, value, disabled, isSelected, valuesByIndexRef, registerSelectedIndex]); - 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; - return 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 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, + }); +}); diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx index 6ee451e5d21..710f946db75 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-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 { AutocompleteContext, type AutocompleteContextValue } from './autocomplete-context'; +import { + AutocompleteContext, + type AutocompleteContextValue, + AutocompleteHighlightContext, + type AutocompleteHighlightContextValue, + AutocompleteOptionsContext, + type AutocompleteOptionsContextValue, +} from './autocomplete-context'; export interface AutocompleteProps { /** Controlled input text. */ @@ -65,14 +72,11 @@ function AutocompleteInner(props: AutocompleteProps) { const arrowRef = useRef(null); const popupRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); - const registerSelectedIndex = useCallback( - (index: number, value: string) => { - if (value === selectedValue) { - setSelectedIndex(index); - } - }, - [selectedValue], - ); + const registerSelectedIndex = useCallback((index: number, selected: boolean) => { + if (selected) { + setSelectedIndex(index); + } + }, []); const previousOpenRef = useRef(open); useEffect(() => { @@ -177,7 +181,6 @@ function AutocompleteInner(props: AutocompleteProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, elementsRef, @@ -188,7 +191,6 @@ function AutocompleteInner(props: AutocompleteProps) { setInlineMode, handleSelect, handleInputChange, - registerSelectedIndex, mounted, transitionProps, }), @@ -202,20 +204,40 @@ function AutocompleteInner(props: AutocompleteProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, handleSelect, handleInputChange, - registerSelectedIndex, mounted, transitionProps, ], ); + const optionsContextValue = useMemo( + () => ({ + getItemProps, + handleSelect, + refs, + valuesByIndexRef, + registerSelectedIndex, + }), + [getItemProps, handleSelect, refs, registerSelectedIndex], + ); + + const highlightContextValue = useMemo( + () => ({ activeIndex, selectedValue }), + [activeIndex, selectedValue], + ); + return ( - {children} + + + + {children} + + + ); } diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx index 1dc42e2f4c9..2a6a642f17c 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx @@ -1,6 +1,6 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef, useState } from 'react'; +import { createRef, Suspense, useDeferredValue, useState, useTransition } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Popover } from '../popover/index'; @@ -1358,3 +1358,243 @@ describe('Autocomplete', () => { }); }); }); + +describe('Autocomplete option renders', () => { + it('re-renders only the options whose highlight changes', async () => { + const user = userEvent.setup(); + const renders: string[] = []; + render( + + + + + {fruits.map(f => ( + { + renders.push(f.value); + return
; + }} + > + {f.label} + + ))} + + + , + ); + await user.click(screen.getByPlaceholderText('Search fruits...')); + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('data-active'); + + renders.length = 0; + await user.keyboard('{ArrowDown}'); + expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('data-active'); + expect(new Set(renders)).toEqual(new Set(['apple', 'banana'])); + + renders.length = 0; + await user.hover(screen.getByRole('option', { name: 'Date' })); + expect(screen.getByRole('option', { name: 'Date' })).toHaveAttribute('data-active'); + expect(new Set(renders)).toEqual(new Set(['banana', 'date'])); + }); +}); + +describe('Autocomplete 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 ( +
+ 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 ( + +

{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