Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mosaic-combobox-option-renders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Reduce option re-renders in `Combobox`, `Autocomplete` and `Select`.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export interface AutocompleteContextValue {
placement: Placement;
getReferenceProps: UseInteractionsReturn['getReferenceProps'];
getFloatingProps: UseInteractionsReturn['getFloatingProps'];
getItemProps: UseInteractionsReturn['getItemProps'];
activeIndex: number | null;
selectedIndex: number | null;
elementsRef: React.MutableRefObject<Array<HTMLElement | null>>;
Expand All @@ -30,7 +29,6 @@ export interface AutocompleteContextValue {
setInlineMode: React.Dispatch<React.SetStateAction<boolean>>;
handleSelect: (value: string, index: number, label: string) => void;
handleInputChange: (value: string) => void;
registerSelectedIndex: (index: number, value: string) => void;
mounted: boolean;
transitionProps: TransitionProps;
}
Expand All @@ -44,3 +42,35 @@ export function useAutocompleteContext() {
}
return ctx;
}

export interface AutocompleteHighlightContextValue {
activeIndex: number | null;
selectedValue: string | undefined;
}
export const AutocompleteHighlightContext = createContext<AutocompleteHighlightContextValue | null>(null);

export function useAutocompleteHighlightContext() {
const ctx = useContext(AutocompleteHighlightContext);
if (!ctx) {
throw new Error('Autocomplete.Option must be used within <Autocomplete>');
}
return ctx;
}

export interface AutocompleteOptionsContextValue {
getItemProps: UseInteractionsReturn['getItemProps'];
handleSelect: (value: string, index: number, label: string) => void;
refs: ExtendedRefs<ReferenceType>;
valuesByIndexRef: React.MutableRefObject<Map<number, string>>;
registerSelectedIndex: (index: number, selected: boolean) => void;
}

export const AutocompleteOptionsContext = createContext<AutocompleteOptionsContextValue | null>(null);

export function useAutocompleteOptionsContext() {
const ctx = useContext(AutocompleteOptionsContext);
if (!ctx) {
throw new Error('Autocomplete.Option must be used within <Autocomplete>');
}
return ctx;
}
139 changes: 79 additions & 60 deletions packages/mosaic/src/primitives/autocomplete/autocomplete-option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useListItem } from '@floating-ui/react';
import React, { useEffect, useId } from 'react';

import { type ComponentProps, type DefaultProps, isHTMLElement, mergeProps, useRender } from '../utils';
import { useAutocompleteContext } from './autocomplete-context';
import { useAutocompleteHighlightContext, useAutocompleteOptionsContext } from './autocomplete-context';

export interface AutocompleteOptionProps extends ComponentProps<'div'> {
value: string;
Expand All @@ -14,72 +14,91 @@ export interface AutocompleteOptionProps extends ComponentProps<'div'> {

export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteOptionProps>(
function AutocompleteOption(props, ref) {
const { render, value, label, disabled, ...otherProps } = props;
const { activeIndex, selectedValue, getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } =
useAutocompleteContext();
const { activeIndex, selectedValue } = useAutocompleteHighlightContext();
const { ref: itemRef, index } = useListItem({ label: props.label ?? props.value });
return (
<AutocompleteOptionBody
{...props}
forwardedRef={ref}
listItemRef={itemRef}
index={index}
isActive={activeIndex === index}
isSelected={selectedValue === props.value}
/>
);
},
);

const id = useId();
const displayLabel = label ?? value;
const { ref: itemRef, index } = useListItem({ label: displayLabel });
interface AutocompleteOptionBodyProps extends AutocompleteOptionProps {
forwardedRef: React.ForwardedRef<HTMLDivElement>;
listItemRef: (node: HTMLElement | null) => void;
index: number;
isActive: boolean;
isSelected: boolean;
}

const isSelected = selectedValue === value;
const isActive = activeIndex === index;
const AutocompleteOptionBody = React.memo(function AutocompleteOptionBody(bodyProps: AutocompleteOptionBodyProps) {
const { forwardedRef: ref, listItemRef: itemRef, index, isActive, isSelected, ...props } = bodyProps;
const { render, value, label, disabled, ...otherProps } = props;
const { getItemProps, handleSelect, valuesByIndexRef, registerSelectedIndex, refs } = useAutocompleteOptionsContext();

useEffect(() => {
const map = valuesByIndexRef.current;
if (!disabled) {
map.set(index, value);
}
registerSelectedIndex(index, value);
return () => {
map.delete(index);
};
}, [index, value, disabled, valuesByIndexRef, registerSelectedIndex]);
const id = useId();
const displayLabel = label ?? value;

const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
useEffect(() => {
const map = valuesByIndexRef.current;
if (!disabled) {
map.set(index, value);
}
registerSelectedIndex(index, isSelected);
return () => {
map.delete(index);
};
}, [index, value, disabled, isSelected, valuesByIndexRef, registerSelectedIndex]);

const ownProps = {
id,
role: 'option',
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
} satisfies DefaultProps<'div'>;
const state = {
selected: isSelected,
active: isActive,
disabled: !!disabled,
};

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
const reference = refs.domReference.current;
if (isHTMLElement(reference)) {
reference.focus();
}
const ownProps = {
id,
role: 'option',
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
} satisfies DefaultProps<'div'>;

const defaultProps = {
...ownProps,
...getItemProps({
onClick() {
if (!disabled) {
handleSelect(value, index, displayLabel);
const reference = refs.domReference.current;
if (isHTMLElement(reference)) {
reference.focus();
}
},
}),
};
}
},
}),
};

const merged = mergeProps<'div'>(defaultProps, otherProps);
// The option id is owned by the primitive and drives the input's
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;
const merged = mergeProps<'div'>(defaultProps, otherProps);
// The option id is owned by the primitive and drives the input's
// aria-activedescendant linkage: a consumer-supplied id must not override it.
merged.id = id;

return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
},
props: merged,
});
},
);
return useRender({
defaultTagName: 'div',
render,
ref: [itemRef, ref],
state,
stateAttributesMapping: {
selected: (v: boolean) => (v ? { 'data-selected': '' } : null),
active: (v: boolean) => (v ? { 'data-active': '' } : null),
disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null),
},
props: merged,
});
});
50 changes: 36 additions & 14 deletions packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import { useControllableState } from '../hooks/use-controllable-state';
import { useTransition } from '../hooks/use-transition';
import { cssVars } from '../utils/css-vars';
import { AutocompleteContext, type AutocompleteContextValue } from './autocomplete-context';
import {
AutocompleteContext,
type AutocompleteContextValue,
AutocompleteHighlightContext,
type AutocompleteHighlightContextValue,
AutocompleteOptionsContext,
type AutocompleteOptionsContextValue,
} from './autocomplete-context';

export interface AutocompleteProps {
/** Controlled input text. */
Expand Down Expand Up @@ -65,14 +72,11 @@ function AutocompleteInner(props: AutocompleteProps) {
const arrowRef = useRef<SVGSVGElement | null>(null);
const popupRef = useRef<HTMLDivElement | null>(null);
const valuesByIndexRef = useRef<Map<number, string>>(new Map());
const registerSelectedIndex = useCallback(
(index: number, value: string) => {
if (value === selectedValue) {
setSelectedIndex(index);
}
},
[selectedValue],
);
const registerSelectedIndex = useCallback((index: number, selected: boolean) => {
if (selected) {
setSelectedIndex(index);
}
}, []);

const previousOpenRef = useRef(open);
useEffect(() => {
Expand Down Expand Up @@ -177,7 +181,6 @@ function AutocompleteInner(props: AutocompleteProps) {
placement,
getReferenceProps,
getFloatingProps,
getItemProps,
activeIndex,
selectedIndex,
elementsRef,
Expand All @@ -188,7 +191,6 @@ function AutocompleteInner(props: AutocompleteProps) {
setInlineMode,
handleSelect,
handleInputChange,
registerSelectedIndex,
mounted,
transitionProps,
}),
Expand All @@ -202,20 +204,40 @@ function AutocompleteInner(props: AutocompleteProps) {
placement,
getReferenceProps,
getFloatingProps,
getItemProps,
activeIndex,
selectedIndex,
handleSelect,
handleInputChange,
registerSelectedIndex,
mounted,
transitionProps,
],
);

const optionsContextValue = useMemo<AutocompleteOptionsContextValue>(
() => ({
getItemProps,
handleSelect,
refs,
valuesByIndexRef,
registerSelectedIndex,
}),
[getItemProps, handleSelect, refs, registerSelectedIndex],
);

const highlightContextValue = useMemo<AutocompleteHighlightContextValue>(
() => ({ activeIndex, selectedValue }),
[activeIndex, selectedValue],
);

return (
<FloatingNode id={nodeId}>
<AutocompleteContext.Provider value={contextValue}>{children}</AutocompleteContext.Provider>
<AutocompleteContext.Provider value={contextValue}>
<AutocompleteOptionsContext.Provider value={optionsContextValue}>
<AutocompleteHighlightContext.Provider value={highlightContextValue}>
{children}
</AutocompleteHighlightContext.Provider>
</AutocompleteOptionsContext.Provider>
</AutocompleteContext.Provider>
</FloatingNode>
);
}
Expand Down
Loading
Loading