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

Improve the performance of the `UserProfile` phone number country picker.
8 changes: 8 additions & 0 deletions packages/mosaic/docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,14 @@ 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Array<HTMLElement | null>>;
Expand All @@ -30,7 +30,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 +43,23 @@ export function useAutocompleteContext() {
}
return ctx;
}

export interface AutocompleteOptionsContextValue {
activeIndexStore: Store<number | null>;
selectedValue: string | undefined;
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,26 +15,33 @@ 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 {
activeIndexStore,
selectedValue,
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 isActive = useStore(activeIndexStore, activeIndex => 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,
Expand Down
50 changes: 36 additions & 14 deletions packages/mosaic/src/primitives/autocomplete/autocomplete-root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useSafeLayoutEffect } from '@clerk/shared/react';
import {
arrow,
autoUpdate,
Expand All @@ -22,7 +23,13 @@ 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 { createStore } from '../utils/store';
import {
AutocompleteContext,
type AutocompleteContextValue,
AutocompleteOptionsContext,
type AutocompleteOptionsContextValue,
} from './autocomplete-context';

export interface AutocompleteProps {
/** Controlled input text. */
Expand Down Expand Up @@ -65,14 +72,16 @@ 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 [activeIndexStore] = useState(() => createStore(activeIndex));
useSafeLayoutEffect(() => {
activeIndexStore.set(activeIndex);
}, [activeIndexStore, activeIndex]);
Comment thread
alexcarpenter marked this conversation as resolved.
Resolved

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

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

return (
<FloatingNode id={nodeId}>
<AutocompleteContext.Provider value={contextValue}>{children}</AutocompleteContext.Provider>
<AutocompleteContext.Provider value={contextValue}>
<AutocompleteOptionsContext.Provider value={optionsContextValue}>
{children}
</AutocompleteOptionsContext.Provider>
</AutocompleteContext.Provider>
</FloatingNode>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<Autocomplete.Root>
<Autocomplete.Input aria-label='Fruit' />
</Autocomplete.Root>,
);

expect(error).not.toHaveBeenCalled();
});
});
Loading
Loading