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
}>{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