From 4a49c4b4087dde298bc0c5a8df29ed3a2f3e2391 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 16:46:48 -0500 Subject: [PATCH 1/6] perf(mosaic): re-render only the list options whose highlight changes --- .changeset/mosaic-combobox-option-renders.md | 5 ++ packages/mosaic/docs/headless.md | 6 ++ .../autocomplete/autocomplete-context.ts | 27 ++++++++- .../autocomplete/autocomplete-option.tsx | 16 ++--- .../autocomplete/autocomplete-root.tsx | 44 +++++++++----- .../autocomplete/autocomplete.test.tsx | 42 +++++++++++++ .../primitives/combobox/combobox-context.ts | 29 ++++++++- .../primitives/combobox/combobox-option.tsx | 24 +++----- .../src/primitives/combobox/combobox-root.tsx | 48 +++++++++------ .../src/primitives/combobox/combobox.test.tsx | 53 ++++++++++++++++ .../select/__tests__/select.test.tsx | 55 +++++++++++++++++ .../src/primitives/select/select-context.ts | 26 +++++++- .../src/primitives/select/select-option.tsx | 21 +++---- .../src/primitives/select/select-root.tsx | 43 +++++++------ .../src/primitives/toast/toast-manager.ts | 49 +++++++-------- packages/mosaic/src/primitives/utils/index.ts | 1 + .../src/primitives/utils/store.test.tsx | 60 +++++++++++++++++++ packages/mosaic/src/primitives/utils/store.ts | 35 +++++++++++ 18 files changed, 462 insertions(+), 122 deletions(-) create mode 100644 .changeset/mosaic-combobox-option-renders.md create mode 100644 packages/mosaic/src/primitives/utils/store.test.tsx create mode 100644 packages/mosaic/src/primitives/utils/store.ts diff --git a/.changeset/mosaic-combobox-option-renders.md b/.changeset/mosaic-combobox-option-renders.md new file mode 100644 index 00000000000..7925370753b --- /dev/null +++ b/.changeset/mosaic-combobox-option-renders.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Moving the highlight in `Combobox`, `Autocomplete`, `Select` and the `PhoneInput` country list with the arrow keys or the pointer is faster, because only the previously and newly highlighted options update instead of the whole list. Selecting an option in a multiple `Select` updates only the options whose selection changed. diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md index cbe49407a7a..7cb955a7f1e 100644 --- a/packages/mosaic/docs/headless.md +++ b/packages/mosaic/docs/headless.md @@ -138,6 +138,12 @@ Any new part with an exit transition needs one of the two; animated element). Add `cssVars()` to floating-ui middleware **after** `arrow()`, or the transform origin won't follow the arrow. +When many parts read one value that changes often (the highlighted option in a +long list), keep it in a `createStore` and read it with +`useStore(store, selector)`, so only the parts whose selected value changed +re-render. Context re-renders every consumer. `primitives/combobox` is the +example. + ## Reading direction Read the direction with `useDirection()` from `primitives/direction-provider`, never from the DOM diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts index b74879e0db1..004071bd28a 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts @@ -8,6 +8,7 @@ import type { import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../hooks/use-transition'; +import type { Store } from '../utils'; export interface AutocompleteContextValue { open: boolean; @@ -19,7 +20,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 +30,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 +43,27 @@ export function useAutocompleteContext() { } return ctx; } + +export interface AutocompleteHighlight { + activeIndex: number | null; + selectedValue: string | undefined; +} + +export interface AutocompleteOptionsContextValue { + store: Store; + 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..89b2c35df6c 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx @@ -3,8 +3,8 @@ 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 { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender, useStore } from '../utils'; +import { useAutocompleteOptionsContext } from './autocomplete-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { value: string; @@ -15,26 +15,26 @@ 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 { store, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = + useAutocompleteOptionsContext(); const id = useId(); const displayLabel = label ?? value; const { ref: itemRef, index } = useListItem({ label: displayLabel }); - const isSelected = selectedValue === value; - const isActive = activeIndex === index; + const isSelected = useStore(store, state => state.selectedValue === value); + const isActive = useStore(store, state => state.activeIndex === index); useEffect(() => { const map = valuesByIndexRef.current; if (!disabled) { map.set(index, value); } - registerSelectedIndex(index, value); + registerSelectedIndex(index, isSelected); return () => { map.delete(index); }; - }, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]); + }, [index, value, disabled, isSelected, valuesByIndexRef, registerSelectedIndex]); const state = { selected: isSelected, diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx index 6ee451e5d21..4f3df61616b 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx @@ -17,12 +17,19 @@ import { useListNavigation, useRole, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { createStore } from '../utils/store'; +import { + AutocompleteContext, + type AutocompleteContextValue, + type AutocompleteHighlight, + AutocompleteOptionsContext, + type AutocompleteOptionsContextValue, +} from './autocomplete-context'; export interface AutocompleteProps { /** Controlled input text. */ @@ -65,14 +72,16 @@ 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 [store] = useState(() => createStore({ activeIndex, selectedValue })); + useLayoutEffect(() => { + store.set({ activeIndex, selectedValue }); + }, [store, activeIndex, selectedValue]); const previousOpenRef = useRef(open); useEffect(() => { @@ -177,7 +186,6 @@ function AutocompleteInner(props: AutocompleteProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, elementsRef, @@ -188,7 +196,6 @@ function AutocompleteInner(props: AutocompleteProps) { setInlineMode, handleSelect, handleInputChange, - registerSelectedIndex, mounted, transitionProps, }), @@ -202,20 +209,27 @@ function AutocompleteInner(props: AutocompleteProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, handleSelect, handleInputChange, - registerSelectedIndex, mounted, transitionProps, ], ); + const optionsContextValue = useMemo( + () => ({ store, getItemProps, handleSelect, refs, valuesByIndexRef, registerSelectedIndex }), + [store, getItemProps, handleSelect, refs, registerSelectedIndex], + ); + 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..2a824621b0a 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx @@ -1358,3 +1358,45 @@ 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'])); + }); +}); diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts index fa1be72ca63..66a4f94c7ec 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -8,6 +8,7 @@ import type { import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../hooks/use-transition'; +import type { Store } from '../utils'; export interface ComboboxContextValue { open: boolean; @@ -20,7 +21,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 +35,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 +48,28 @@ export function useComboboxContext() { } return ctx; } + +export interface ComboboxHighlight { + activeIndex: number | null; + selectedValue: string | null; +} + +export interface ComboboxOptionsContextValue { + store: Store; + 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 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..bc8a573d3cb 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -3,8 +3,8 @@ 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 { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender, useStore } from '../utils'; +import { useComboboxOptionsContext } from './combobox-context'; import { ComboboxOptionContext } from './combobox-option-context'; export interface ComboboxOptionProps extends ComponentProps<'div'> { @@ -16,36 +16,28 @@ 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 { store, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, registerOption, refs } = + useComboboxOptionsContext(); const id = useId(); const displayLabel = label ?? value; const { ref: itemRef, index } = useListItem({ label: displayLabel }); - const isSelected = selectedValue === value; - const isActive = activeIndex === index; + const isSelected = useStore(store, state => state.selectedValue === value); + const isActive = useStore(store, state => state.activeIndex === index); useEffect(() => { const map = valuesByIndexRef.current; if (!disabled) { map.set(index, value); } - const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel); + const unregisterSelectedIndex = registerSelectedIndex(index, value, displayLabel, isSelected); registerOption(index); return () => { map.delete(index); unregisterSelectedIndex?.(); }; - }, [index, value, displayLabel, disabled, valuesByIndexRef, registerSelectedIndex, registerOption]); + }, [index, value, displayLabel, disabled, isSelected, valuesByIndexRef, registerSelectedIndex, registerOption]); const state = { selected: isSelected, diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index 60f70ddec0e..53c709f811a 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -17,12 +17,19 @@ import { useListNavigation, useRole, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; 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 { createStore } from '../utils/store'; +import { + ComboboxContext, + type ComboboxContextValue, + type ComboboxHighlight, + ComboboxOptionsContext, + type ComboboxOptionsContextValue, +} from './combobox-context'; export interface ComboboxProps { /** Controlled input text. */ @@ -115,17 +122,19 @@ 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 [store] = useState(() => createStore({ activeIndex, selectedValue })); + useLayoutEffect(() => { + store.set({ activeIndex, selectedValue }); + }, [store, activeIndex, selectedValue]); const previousOpenRef = useRef(open); useEffect(() => { @@ -251,7 +260,6 @@ function ComboboxInner(props: ComboboxProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, elementsRef, @@ -266,8 +274,6 @@ function ComboboxInner(props: ComboboxProps) { setOpen, focusInput, popupId, - registerSelectedIndex, - registerOption, mounted, transitionProps, }), @@ -282,7 +288,6 @@ function ComboboxInner(props: ComboboxProps) { placement, getReferenceProps, getFloatingProps, - getItemProps, activeIndex, selectedIndex, handleSelect, @@ -290,16 +295,21 @@ function ComboboxInner(props: ComboboxProps) { setOpen, focusInput, popupId, - registerSelectedIndex, - registerOption, mounted, transitionProps, ], ); + const optionsContextValue = useMemo( + () => ({ store, getItemProps, handleSelect, refs, valuesByIndexRef, registerSelectedIndex, registerOption }), + [store, getItemProps, handleSelect, refs, registerSelectedIndex, registerOption], + ); + 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..e16f9e7c568 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -612,3 +612,56 @@ 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'); + }); +}); diff --git a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx index 99c8fee0916..2d1f9107e17 100644 --- a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx +++ b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx @@ -1077,3 +1077,58 @@ describe('Select.Group', () => { expect(() => render(Fruits)).toThrow(/within /); }); }); + +describe('Select option renders', () => { + it('re-renders only the options whose highlight or selection changes', 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'] }); + return ( +
+ {props.children} + {suspendSelected && props['aria-selected'] ? : 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(); + }); +}); diff --git a/packages/mosaic/src/primitives/combobox/combobox-context.ts b/packages/mosaic/src/primitives/combobox/combobox-context.ts index 66a4f94c7ec..a2c9fac1ac0 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -49,13 +49,9 @@ export function useComboboxContext() { return ctx; } -export interface ComboboxHighlight { - activeIndex: number | null; - selectedValue: string | null; -} - export interface ComboboxOptionsContextValue { - store: Store; + activeIndexStore: Store; + selectedValue: string | null; getItemProps: UseInteractionsReturn['getItemProps']; handleSelect: (value: string, index: number, label: string) => void; refs: ExtendedRefs; diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx index bc8a573d3cb..47356aa67ea 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -16,15 +16,23 @@ export interface ComboboxOptionProps extends ComponentProps<'div'> { export const ComboboxOption = React.forwardRef( function ComboboxOption(props, ref) { const { render, value, label, disabled, ...otherProps } = props; - const { store, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, registerOption, refs } = - useComboboxOptionsContext(); + const { + activeIndexStore, + selectedValue, + getItemProps, + handleSelect, + valuesByIndexRef, + registerSelectedIndex, + registerOption, + refs, + } = useComboboxOptionsContext(); const id = useId(); const displayLabel = label ?? value; const { ref: itemRef, index } = useListItem({ label: displayLabel }); - const isSelected = useStore(store, state => state.selectedValue === value); - const isActive = useStore(store, state => state.activeIndex === index); + const isSelected = selectedValue === value; + const isActive = useStore(activeIndexStore, activeIndex => activeIndex === index); useEffect(() => { const map = valuesByIndexRef.current; diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index 53c709f811a..d8016171eeb 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -26,7 +26,6 @@ import { createStore } from '../utils/store'; import { ComboboxContext, type ComboboxContextValue, - type ComboboxHighlight, ComboboxOptionsContext, type ComboboxOptionsContextValue, } from './combobox-context'; @@ -131,10 +130,10 @@ function ComboboxInner(props: ComboboxProps) { return () => setSelectedIndex(current => (current === index ? null : current)); }, []); - const [store] = useState(() => createStore({ activeIndex, selectedValue })); + const [activeIndexStore] = useState(() => createStore(activeIndex)); useLayoutEffect(() => { - store.set({ activeIndex, selectedValue }); - }, [store, activeIndex, selectedValue]); + activeIndexStore.set(activeIndex); + }, [activeIndexStore, activeIndex]); const previousOpenRef = useRef(open); useEffect(() => { @@ -301,8 +300,17 @@ function ComboboxInner(props: ComboboxProps) { ); const optionsContextValue = useMemo( - () => ({ store, getItemProps, handleSelect, refs, valuesByIndexRef, registerSelectedIndex, registerOption }), - [store, getItemProps, handleSelect, refs, registerSelectedIndex, registerOption], + () => ({ + activeIndexStore, + selectedValue, + getItemProps, + handleSelect, + refs, + valuesByIndexRef, + registerSelectedIndex, + registerOption, + }), + [activeIndexStore, selectedValue, getItemProps, handleSelect, refs, registerSelectedIndex, registerOption], ); return ( diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index e16f9e7c568..adb32639abe 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'; @@ -665,3 +665,145 @@ describe('Combobox option renders', () => { 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 OptionRender = { option: string; parentValue: string | null; selected: unknown }; + + function Fixture({ + update = 'transition', + onRenderOption, + suspendSelected = false, + }: { + update?: 'transition' | 'deferred'; + onRenderOption?: (render: OptionRender) => void; + suspendSelected?: boolean; + }) { + const [value, setValue] = useState(null); + const deferredValue = useDeferredValue(value); + const [isPending, startTransition] = useTransition(); + 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'] }); + return ( +
+ {props.children} + {suspendSelected && props['aria-selected'] ? : 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(); + }); +}); diff --git a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx index 2d1f9107e17..cc95a72348d 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'; @@ -1129,6 +1129,153 @@ describe('Select option renders', () => { await user.keyboard('{Enter}'); expect(screen.getByRole('option', { name: 'Cherry' })).toHaveAttribute('aria-selected', 'true'); expect(screen.getByRole('option', { name: 'Banana' })).toHaveAttribute('aria-selected', 'true'); - 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 OptionRender = { option: string; parentValue: string; selected: unknown }; + + function Fixture({ + update = 'transition', + onRenderOption, + suspendSelected = false, + }: { + update?: 'transition' | 'deferred'; + onRenderOption?: (render: OptionRender) => void; + suspendSelected?: boolean; + }) { + const [value, setValue] = useState(''); + const deferredValue = useDeferredValue(value); + const [isPending, startTransition] = useTransition(); + 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'] }); + 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(); }); }); diff --git a/packages/mosaic/src/primitives/select/select-context.ts b/packages/mosaic/src/primitives/select/select-context.ts index 112121f4357..f288cb59aa3 100644 --- a/packages/mosaic/src/primitives/select/select-context.ts +++ b/packages/mosaic/src/primitives/select/select-context.ts @@ -60,13 +60,9 @@ export function useSelectContext() { return ctx; } -export interface SelectHighlight { - activeIndex: number | null; - selectedValues: string[]; -} - export interface SelectOptionsContextValue { - store: Store; + activeIndexStore: Store; + selectedValues: string[]; getItemProps: UseInteractionsReturn['getItemProps']; handleSelect: (value: string, index: number) => void; setSelectedIndex: React.Dispatch>; diff --git a/packages/mosaic/src/primitives/select/select-option.tsx b/packages/mosaic/src/primitives/select/select-option.tsx index f97f1638b1d..a389f59d349 100644 --- a/packages/mosaic/src/primitives/select/select-option.tsx +++ b/packages/mosaic/src/primitives/select/select-option.tsx @@ -14,15 +14,22 @@ export interface SelectOptionProps extends ComponentProps<'button'> { export const SelectOption = React.forwardRef(function SelectOption(props, ref) { const { render, value, label, disabled, ...otherProps } = props; - const { store, setSelectedIndex, getItemProps, handleSelect, valueToLabelRef, selectedItemRef } = - useSelectOptionsContext(); + const { + activeIndexStore, + selectedValues, + setSelectedIndex, + getItemProps, + handleSelect, + valueToLabelRef, + selectedItemRef, + } = useSelectOptionsContext(); const displayLabel = label ?? value; const { ref: itemRef, index } = useListItem({ label: displayLabel }); - const isSelected = useStore(store, state => state.selectedValues.includes(value)); - const isAnchor = useStore(store, state => state.selectedValues.at(-1) === value); - const isActive = useStore(store, state => state.activeIndex === index); + const isSelected = selectedValues.includes(value); + const isAnchor = selectedValues.at(-1) === value; + const isActive = useStore(activeIndexStore, activeIndex => activeIndex === index); useEffect(() => { const map = valueToLabelRef.current; diff --git a/packages/mosaic/src/primitives/select/select-root.tsx b/packages/mosaic/src/primitives/select/select-root.tsx index 10940c35ac7..ab4072f4966 100644 --- a/packages/mosaic/src/primitives/select/select-root.tsx +++ b/packages/mosaic/src/primitives/select/select-root.tsx @@ -31,7 +31,6 @@ import { alignSelectedItem } from './align-selected-item'; import { SelectContext, type SelectContextValue, - type SelectHighlight, type SelectItem, SelectOptionsContext, type SelectOptionsContextValue, @@ -108,10 +107,10 @@ function SelectInner(props: SelectProps) { const [cachedLabels, setCachedLabels] = useState>(new Map()); const [activeIndex, setActiveIndex] = useState(null); - const [store] = useState(() => createStore({ activeIndex, selectedValues })); + const [activeIndexStore] = useState(() => createStore(activeIndex)); useLayoutEffect(() => { - store.set({ activeIndex, selectedValues }); - }, [store, activeIndex, selectedValues]); + activeIndexStore.set(activeIndex); + }, [activeIndexStore, activeIndex]); const elementsRef = useRef>([]); const labelsRef = useRef>([]); @@ -199,8 +198,9 @@ function SelectInner(props: SelectProps) { const handleSelect = useCallback( (value: string, index: number) => { if (multiple) { - const current = store.get().selectedValues; - const next = current.includes(value) ? current.filter(selected => selected !== value) : [...current, value]; + const next = selectedValues.includes(value) + ? selectedValues.filter(selected => selected !== value) + : [...selectedValues, value]; setSelectedValues(next); onMultipleValueChange?.(next); if (next.length === 0) { @@ -223,7 +223,15 @@ function SelectInner(props: SelectProps) { handleOpenChange(false); } }, - [store, multiple, isControlled, setSelectedValues, handleOpenChange, onSingleValueChange, onMultipleValueChange], + [ + multiple, + selectedValues, + isControlled, + setSelectedValues, + handleOpenChange, + onSingleValueChange, + onMultipleValueChange, + ], ); const handleTypeaheadMatch = useCallback( @@ -318,8 +326,16 @@ function SelectInner(props: SelectProps) { ); const optionsContextValue = useMemo( - () => ({ store, getItemProps, handleSelect, setSelectedIndex, valueToLabelRef, selectedItemRef }), - [store, getItemProps, handleSelect], + () => ({ + activeIndexStore, + selectedValues, + getItemProps, + handleSelect, + setSelectedIndex, + valueToLabelRef, + selectedItemRef, + }), + [activeIndexStore, selectedValues, getItemProps, handleSelect], ); return ( From 2b999f84115b19f24aee8cf377a439d2bf5810bd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 4 Nov 2026 08:13:16 -0500 Subject: [PATCH 4/6] fix(mosaic): avoid the server useLayoutEffect warning in list roots --- .../autocomplete/autocomplete-root.tsx | 5 +++-- .../autocomplete/autocomplete.ssr.test.tsx | 19 +++++++++++++++++ .../src/primitives/combobox/combobox-root.tsx | 5 +++-- .../primitives/combobox/combobox.ssr.test.tsx | 19 +++++++++++++++++ .../src/primitives/select/select-root.tsx | 5 +++-- .../src/primitives/select/select.ssr.test.tsx | 21 +++++++++++++++++++ 6 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx create mode 100644 packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx create mode 100644 packages/mosaic/src/primitives/select/select.ssr.test.tsx diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx index d79dfe78838..f981e300073 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; import { arrow, autoUpdate, @@ -17,7 +18,7 @@ import { useListNavigation, useRole, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { useTransition } from '../hooks/use-transition'; @@ -78,7 +79,7 @@ function AutocompleteInner(props: AutocompleteProps) { }, []); const [activeIndexStore] = useState(() => createStore(activeIndex)); - useLayoutEffect(() => { + useSafeLayoutEffect(() => { activeIndexStore.set(activeIndex); }, [activeIndexStore, activeIndex]); diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx new file mode 100644 index 00000000000..b28ac1ff5a8 --- /dev/null +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx @@ -0,0 +1,19 @@ +// @vitest-environment node +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { Autocomplete } from './index'; + +describe('Autocomplete SSR', () => { + it('renders on the server without warnings', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderToString( + + + , + ); + + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index d8016171eeb..942d6671705 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; import { arrow, autoUpdate, @@ -17,7 +18,7 @@ import { useListNavigation, useRole, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { useTransition } from '../hooks/use-transition'; @@ -131,7 +132,7 @@ function ComboboxInner(props: ComboboxProps) { }, []); const [activeIndexStore] = useState(() => createStore(activeIndex)); - useLayoutEffect(() => { + useSafeLayoutEffect(() => { activeIndexStore.set(activeIndex); }, [activeIndexStore, activeIndex]); diff --git a/packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx new file mode 100644 index 00000000000..abcc2b23695 --- /dev/null +++ b/packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx @@ -0,0 +1,19 @@ +// @vitest-environment node +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { Combobox } from './index'; + +describe('Combobox SSR', () => { + it('renders on the server without warnings', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderToString( + + + , + ); + + expect(error).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mosaic/src/primitives/select/select-root.tsx b/packages/mosaic/src/primitives/select/select-root.tsx index ab4072f4966..f9fab2dfdac 100644 --- a/packages/mosaic/src/primitives/select/select-root.tsx +++ b/packages/mosaic/src/primitives/select/select-root.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useSafeLayoutEffect } from '@clerk/shared/react'; import { arrow, autoUpdate, @@ -20,7 +21,7 @@ import { useRole, useTypeahead, } from '@floating-ui/react'; -import { type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { useReturnFocus } from '../hooks/use-return-focus'; @@ -108,7 +109,7 @@ function SelectInner(props: SelectProps) { const [activeIndex, setActiveIndex] = useState(null); const [activeIndexStore] = useState(() => createStore(activeIndex)); - useLayoutEffect(() => { + useSafeLayoutEffect(() => { activeIndexStore.set(activeIndex); }, [activeIndexStore, activeIndex]); diff --git a/packages/mosaic/src/primitives/select/select.ssr.test.tsx b/packages/mosaic/src/primitives/select/select.ssr.test.tsx new file mode 100644 index 00000000000..7a312cb033a --- /dev/null +++ b/packages/mosaic/src/primitives/select/select.ssr.test.tsx @@ -0,0 +1,21 @@ +// @vitest-environment node +import { renderToString } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { Select } from './index'; + +describe('Select SSR', () => { + it('renders on the server without warnings', () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + renderToString( + + + + + , + ); + + expect(error).not.toHaveBeenCalled(); + }); +}); From 4ff1853321e92f1741fbffe15b79bbea203f0846 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 4 Nov 2026 10:07:16 -0500 Subject: [PATCH 5/6] perf(mosaic): replace the option store with context and memoized option bodies --- packages/mosaic/docs/headless.md | 8 - .../autocomplete/autocomplete-context.ts | 17 +- .../autocomplete/autocomplete-option.tsx | 148 +++++++++-------- .../autocomplete/autocomplete-root.tsx | 22 +-- .../autocomplete/autocomplete.ssr.test.tsx | 19 --- .../autocomplete/autocomplete.test.tsx | 56 ++++++- .../primitives/combobox/combobox-context.ts | 17 +- .../primitives/combobox/combobox-option.tsx | 156 ++++++++++-------- .../src/primitives/combobox/combobox-root.tsx | 24 +-- .../primitives/combobox/combobox.ssr.test.tsx | 19 --- .../src/primitives/combobox/combobox.test.tsx | 56 ++++++- .../select/__tests__/select.test.tsx | 52 ++++++ .../src/primitives/select/select-context.ts | 17 +- .../src/primitives/select/select-option.tsx | 45 +++-- .../src/primitives/select/select-root.tsx | 22 +-- .../src/primitives/select/select.ssr.test.tsx | 21 --- .../src/primitives/toast/toast-manager.ts | 49 +++--- packages/mosaic/src/primitives/utils/index.ts | 1 - .../src/primitives/utils/store.test.tsx | 60 ------- packages/mosaic/src/primitives/utils/store.ts | 35 ---- 20 files changed, 459 insertions(+), 385 deletions(-) delete mode 100644 packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx delete mode 100644 packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx delete mode 100644 packages/mosaic/src/primitives/select/select.ssr.test.tsx delete mode 100644 packages/mosaic/src/primitives/utils/store.test.tsx delete mode 100644 packages/mosaic/src/primitives/utils/store.ts diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md index 0648b85f278..cbe49407a7a 100644 --- a/packages/mosaic/docs/headless.md +++ b/packages/mosaic/docs/headless.md @@ -138,14 +138,6 @@ Any new part with an exit transition needs one of the two; animated element). Add `cssVars()` to floating-ui middleware **after** `arrow()`, or the transform origin won't follow the arrow. -When many parts read one value that changes often and only from the -primitive's own events (the highlighted option in a long list), keep it in a -`createStore` and read it with `useStore(store, selector)`, so only the parts -whose selected value changed re-render. Keep values a consumer can pass as -props, like a controlled `value`, in context: the store updates after commit, -so parts would render a stale value and lose `startTransition` and Suspense. -`primitives/combobox` is the example. - ## Reading direction Read the direction with `useDirection()` from `primitives/direction-provider`, never from the DOM diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts index 60bbea14e9e..a3b6d7bc804 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-context.ts @@ -8,7 +8,6 @@ import type { import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../hooks/use-transition'; -import type { Store } from '../utils'; export interface AutocompleteContextValue { open: boolean; @@ -44,9 +43,21 @@ export function useAutocompleteContext() { return ctx; } -export interface AutocompleteOptionsContextValue { - activeIndexStore: Store; +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; diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx index 2d02ba1df08..76d1b2cf3d9 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx @@ -3,8 +3,8 @@ import { useListItem } from '@floating-ui/react'; import React, { useEffect, useId } from 'react'; -import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender, useStore } from '../utils'; -import { useAutocompleteOptionsContext } from './autocomplete-context'; +import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender } from '../utils'; +import { useAutocompleteHighlightContext, useAutocompleteOptionsContext } from './autocomplete-context'; export interface AutocompleteOptionProps extends ComponentProps<'div'> { value: string; @@ -14,79 +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 { - activeIndexStore, - selectedValue, - getItemProps, - handleSelect, - valuesByIndexRef, - registerSelectedIndex, - refs, - } = useAutocompleteOptionsContext(); + 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 = useStore(activeIndexStore, activeIndex => 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, isSelected); - return () => { - map.delete(index); - }; - }, [index, value, disabled, isSelected, 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 f981e300073..710f946db75 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useSafeLayoutEffect } from '@clerk/shared/react'; import { arrow, autoUpdate, @@ -23,10 +22,11 @@ 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 { createStore } from '../utils/store'; import { AutocompleteContext, type AutocompleteContextValue, + AutocompleteHighlightContext, + type AutocompleteHighlightContextValue, AutocompleteOptionsContext, type AutocompleteOptionsContextValue, } from './autocomplete-context'; @@ -78,11 +78,6 @@ function AutocompleteInner(props: AutocompleteProps) { } }, []); - const [activeIndexStore] = useState(() => createStore(activeIndex)); - useSafeLayoutEffect(() => { - activeIndexStore.set(activeIndex); - }, [activeIndexStore, activeIndex]); - const previousOpenRef = useRef(open); useEffect(() => { if (open && !previousOpenRef.current && selectedIndex != null) { @@ -220,22 +215,27 @@ function AutocompleteInner(props: AutocompleteProps) { const optionsContextValue = useMemo( () => ({ - activeIndexStore, - selectedValue, getItemProps, handleSelect, refs, valuesByIndexRef, registerSelectedIndex, }), - [activeIndexStore, selectedValue, getItemProps, handleSelect, refs, registerSelectedIndex], + [getItemProps, handleSelect, refs, registerSelectedIndex], + ); + + const highlightContextValue = useMemo( + () => ({ activeIndex, selectedValue }), + [activeIndex, selectedValue], ); return ( - {children} + + {children} + diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx deleted file mode 100644 index b28ac1ff5a8..00000000000 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete.ssr.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// @vitest-environment node -import { renderToString } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; - -import { Autocomplete } from './index'; - -describe('Autocomplete SSR', () => { - it('renders on the server without warnings', () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - - renderToString( - - - , - ); - - expect(error).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx b/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx index 7f8b9bfd5e2..2a6a642f17c 100644 --- a/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx +++ b/packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx @@ -1408,20 +1408,28 @@ describe('Autocomplete selection in a transition', () => { 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; @@ -1452,10 +1460,24 @@ describe('Autocomplete selection in a transition', () => { label={option.label} render={props => { 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}
); }} @@ -1543,4 +1565,36 @@ describe('Autocomplete selection in a transition', () => { 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 a2c9fac1ac0..f051b556a82 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-context.ts +++ b/packages/mosaic/src/primitives/combobox/combobox-context.ts @@ -8,7 +8,6 @@ import type { import { createContext, type CSSProperties, useContext } from 'react'; import type { TransitionProps } from '../hooks/use-transition'; -import type { Store } from '../utils'; export interface ComboboxContextValue { open: boolean; @@ -50,8 +49,6 @@ export function useComboboxContext() { } export interface ComboboxOptionsContextValue { - activeIndexStore: Store; - selectedValue: string | null; getItemProps: UseInteractionsReturn['getItemProps']; handleSelect: (value: string, index: number, label: string) => void; refs: ExtendedRefs; @@ -60,6 +57,20 @@ export interface ComboboxOptionsContextValue { 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() { diff --git a/packages/mosaic/src/primitives/combobox/combobox-option.tsx b/packages/mosaic/src/primitives/combobox/combobox-option.tsx index 47356aa67ea..9dd1c19d31c 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-option.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-option.tsx @@ -3,8 +3,8 @@ import { useListItem } from '@floating-ui/react'; import React, { useEffect, useId } from 'react'; -import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender, useStore } from '../utils'; -import { useComboboxOptionsContext } from './combobox-context'; +import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender } from '../utils'; +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 { - activeIndexStore, - selectedValue, - getItemProps, - handleSelect, - valuesByIndexRef, - registerSelectedIndex, - registerOption, - refs, - } = useComboboxOptionsContext(); + 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 = useStore(activeIndexStore, activeIndex => 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, isSelected); - registerOption(index); - return () => { - map.delete(index); - unregisterSelectedIndex?.(); - }; - }, [index, value, displayLabel, disabled, isSelected, 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 942d6671705..7c6a01620b5 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useSafeLayoutEffect } from '@clerk/shared/react'; import { arrow, autoUpdate, @@ -23,10 +22,11 @@ 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 { createStore } from '../utils/store'; import { ComboboxContext, type ComboboxContextValue, + ComboboxHighlightContext, + type ComboboxHighlightContextValue, ComboboxOptionsContext, type ComboboxOptionsContextValue, } from './combobox-context'; @@ -131,11 +131,6 @@ function ComboboxInner(props: ComboboxProps) { return () => setSelectedIndex(current => (current === index ? null : current)); }, []); - const [activeIndexStore] = useState(() => createStore(activeIndex)); - useSafeLayoutEffect(() => { - activeIndexStore.set(activeIndex); - }, [activeIndexStore, activeIndex]); - const previousOpenRef = useRef(open); useEffect(() => { if (open && !previousOpenRef.current && selectedIndex != null) { @@ -302,8 +297,6 @@ function ComboboxInner(props: ComboboxProps) { const optionsContextValue = useMemo( () => ({ - activeIndexStore, - selectedValue, getItemProps, handleSelect, refs, @@ -311,13 +304,22 @@ function ComboboxInner(props: ComboboxProps) { registerSelectedIndex, registerOption, }), - [activeIndexStore, selectedValue, getItemProps, handleSelect, refs, 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.ssr.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx deleted file mode 100644 index abcc2b23695..00000000000 --- a/packages/mosaic/src/primitives/combobox/combobox.ssr.test.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// @vitest-environment node -import { renderToString } from 'react-dom/server'; -import { describe, expect, it, vi } from 'vitest'; - -import { Combobox } from './index'; - -describe('Combobox SSR', () => { - it('renders on the server without warnings', () => { - const error = vi.spyOn(console, 'error').mockImplementation(() => {}); - - renderToString( - - - , - ); - - expect(error).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/mosaic/src/primitives/combobox/combobox.test.tsx b/packages/mosaic/src/primitives/combobox/combobox.test.tsx index adb32639abe..d57bedd001b 100644 --- a/packages/mosaic/src/primitives/combobox/combobox.test.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox.test.tsx @@ -673,20 +673,28 @@ describe('Combobox selection in a transition', () => { 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; @@ -716,10 +724,24 @@ describe('Combobox selection in a transition', () => { label={option.label} render={props => { 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}
); }} @@ -806,4 +828,36 @@ describe('Combobox selection in a transition', () => { 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 cc95a72348d..92250d3102e 100644 --- a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx +++ b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx @@ -1139,20 +1139,28 @@ describe('Select selection in a transition', () => { 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; @@ -1187,13 +1195,25 @@ describe('Select selection in a transition', () => { label={option.label} render={props => { onRenderOption?.({ option: option.value, parentValue, selected: props['aria-selected'] }); + const active = 'data-active' in props; + onRenderHover?.({ option: option.value, hovered, active }); return ( ); }} @@ -1278,4 +1298,36 @@ describe('Select selection in a transition', () => { 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 f288cb59aa3..6f4f480f392 100644 --- a/packages/mosaic/src/primitives/select/select-context.ts +++ b/packages/mosaic/src/primitives/select/select-context.ts @@ -8,7 +8,6 @@ import type { import { createContext, type CSSProperties, type RefObject, useContext } from 'react'; import type { TransitionProps } from '../hooks/use-transition'; -import type { Store } from '../utils'; export interface SelectItem { label: string; @@ -60,9 +59,21 @@ export function useSelectContext() { return ctx; } -export interface SelectOptionsContextValue { - activeIndexStore: Store; +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