Repository: VKCOM/VKUI
Pull request #8365: fix(CustomSelect): fix props.options and local state option syncronization
Author: login: andrey-medvedev-vk
Created: 2025-03-13T15:08:34Z | Merged: 2025-03-26T07:39:54Z

<!-- Если этот PR закрывает Issue, то укажи ссылку на него. Используй доступные ключевые слова (см. https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/using-keywords-in-issues-and-pull-requests). -->
- close #8334 

---

<!-- Чеклист. Лишние пункты можно удалить если изменения не подразумевают их наличие. Иначе, необходимо обоснование по каждому пункту. -->
- [x] Unit-тесты
- [x] e2e-тесты
- [ ] Дизайн-ревью
- [ ] Документация фичи
- [x] Release notes

## Описание
В #8334 видно, что при асинхронной загрузке опций есть лаг между тем когда `fetching` уже не `true`, но опций ещё нет и мы в дропдауне, вместо лоадера, выводим `Ничего не найдено`.
Опций нет по той причине, что рендерим мы опции из локального стейта, который мы синхронизируем с props.options, а синхронизируем мы через `useEffect`. 
Получается, что должен сработать `useEffect` прежде чем новые, загруженные опции пользователя, переданные через `props.options`, попадут в рендер дропдауна.

## Изменения
- Убран локальный стейт `options`, вместо этого `options` это результат фильтрации `props.options`, обёрнутой в `useMemo`.
- Изменилась логика проверки необходимости вызова `onChange` при клике на опцию из списка.
Раньше она была основана на проверке по индексам опций в списке, типа `﻿﻿selectedOptionIndex`, индекс брался из`options`, отфильтрованной версии `props.options`.
Теперь такая логика не работает для принятия решения по вызову `props.onChange`, потому что `options` фильтруются быстрее, а значит ссылаться на опцию по индексу опасно, индекс опции уже может поменяться между кликом по опции и моментом, когда мы начнём принимать решение о вызове `onChange`.
Переехали на проверку по значению `nativeSelectValue`/`prevNativeSelectValue`, а не по индексу.

## Release notes
## Исправления
- CustomSelect: исправлена задержки вывода опций, переданных в `props.options` при асинхронной загрузке
<!-- 
Необходимо описать основные изменения, которые будут отображены в release notes релиза, в который попадет задача
Формат следующий:
- Если вы не хотите, чтобы в Release notes попала информация о вашем PR, то оставьте просто "-"
- Изменения нужно сгруппировать в секции. Можно указать несколько секций, порядок не важен. Секция должна быть заголовом второго уровня (`## ${заголовок}`). Ниже список секций
  - Новые компоненты
  - Улучшения
  - Исправления
  - Документация
  - Зависимости
  - BREAKING CHANGE
- В каждой секции нужно указать список изменений
- Каждый пункт изменений должен начинаться с '-'
- Если изменение касается какого-то конкретного компонента, то его название должно быть указано и отделено от описания через ':'
Пример:
> - CustomSelect: поправлен баг с неправильным позиционированием

или

> - [CustomSelect](https://vkcom.github.io/VKUI/${version}/#/CustomSelect): поправил баг с неправильным позиционированием 

- Если изменений по одному компоненту несколько, их нужно указать в следующем формате
> - CustomSelect:
>   - Поправлен баг с позиционированием
>   - Добавлен новый props

- Если изменение не касается конкретного компонента, то его нужно также указать через '-' и далее в свободной форме
Пример:
> - Переделан механизм отображения всех модальных окон

- Для каждого пункта можно добавить дополнительную информацию. Ее можно указать на следующей строке после описания изменения

Пример:
> ## Новые компоненты
> - Button: компонент, для отображения кнопок
> Более подробное описание
> {Картинка нового компонента}

-->


Linked issue #8334: [Bug]: Рассинхрон в CustomSelect
### Описание

При переходе на createRoot реакт стал успевать отрисовывать  состояние компонента, когда options переданы новые (и они есть), а отрисовывается (на 1 мс) так будто их нет. 

Возможно это из-за того, что renderDropdown обернут в useMemo, который не зависит от пропа options напрямую. (Зависит от одноименного стейта, который обновляется позже). Получаем ситуацию, когда список новый в компоненте есть, а рендер происходит со старыми данными. 

https://github.com/user-attachments/assets/c9795782-6c37-4b5c-b375-64341bd4384e

### Версия

7.0.0

### В каких браузерах воспроизводится проблема?

Chrome

### Шаги воспроизведения

_No response_

### Ожидаемое поведение

_No response_

### Скриншоты

_No response_

### Пример с воспроизведением

https://codesandbox.io/p/sandbox/currying-leaf-7pql32?file=%2Fsrc%2FApp.js%3A10%2C37
Linked issue comments: user: login: vkcom-publisher
created_at: 2025-03-26T10:20:47Z
body: <!-- disable_global_notification -->✅ <a href="https://github.com/VKCOM/VKUI/releases/tag/v7.2.0" target="_blank">v7.2.0</a> 🎉
author_association: CONTRIBUTOR

Diff:
diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx
index e57c0abbe6f..53d499d59d1 100644
--- a/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx
+++ b/packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx
@@ -732,6 +732,34 @@ describe('CustomSelect', () => {
     expect(onChange.mock.calls[0][1]).toBe('1');
   });
 
+  it('(uncontrolled): doesn not calls onChange when click default selected option when defaultValue is string and option value is number', async () => {
+    const onChange = jest.fn();
+
+    render(
+      <CustomSelect
+        labelTextTestId="labelTextTestId"
+        options={[
+          { value: 0, label: 'Mike' },
+          { value: 1, label: 'Josh' },
+        ]}
+        allowClearButton
+        onChange={onChange}
+        defaultValue="0"
+      />,
+    );
+
+    expect(onChange).toHaveBeenCalledTimes(0);
+    expect(getCustomSelectValue()).toEqual('Mike');
+
+    fireEvent.click(screen.getByTestId('labelTextTestId'));
+    expect(screen.getByRole('option', { selected: true })).toHaveTextContent('Mike');
+    const selectedOption = screen.getByRole('option', { selected: true, name: 'Mike' });
+    fireEvent.mouseEnter(selectedOption);
+    fireEvent.click(selectedOption);
+
+    expect(onChange).toHaveBeenCalledTimes(0);
+  });
+
   it('(controlled): calls onChange expected amount of times after clearing component and clicking on option without updating controlled prop value', async () => {
     // мы намеренно проверяем кейсы где при нажатии на опцию или на кнопку очистки value проп не меняется
     const onChange = jest.fn();
@@ -1373,12 +1401,8 @@ describe('CustomSelect', () => {
   });
 
   it('should not hover disabled option', async () => {
-    const inputRef: React.RefObject<HTMLInputElement | null> = {
-      current: null,
-    };
     render(
       <CustomSelect
-        getSelectInputRef={inputRef}
         data-testid="select"
         options={[
           { value: '0', label: 'Не выбрано' },
@@ -1396,6 +1420,48 @@ describe('CustomSelect', () => {
     expect(option.getAttribute('data-hovered')).toBe('false');
   });
 
+  it.each([
+    {
+      testName: 'should not lose hover over option on onMouseLeave event when mouse is not moved',
+      expectedHover: 'true',
+    },
+    {
+      testName: 'should lose hover over option on onMouseLeave event when mouse moved',
+      expectedHover: 'false',
+    },
+  ])('$testName', async ({ expectedHover }) => {
+    const inputRef: React.RefObject<HTMLInputElement | null> = {
+      current: null,
+    };
+    render(
+      <CustomSelect
+        data-testid="select"
+        options={[
+          { value: '0', label: 'Не выбрано' },
+          { value: '1', label: 'Категория 1' },
+          { value: '2', label: 'Категория 2' },
+          { value: '3', label: 'Категория 3' },
+        ]}
+        defaultValue="1"
+        getSelectInputRef={inputRef}
+      />,
+    );
+    fireEvent.click(screen.getByTestId('select'));
+    const option = screen.getByRole('option', { name: 'Категория 1' });
+
+    expect(option.getAttribute('data-hovered')).toBe('true');
+
+    if (expectedHover === 'false') {
+      await React.act(async () =>
+        fireEvent.mouseMove(inputRef.current!, { clientY: 20, clientX: 20 }),
+      );
+    }
+
+    await React.act(async () => fireEvent.mouseLeave(option));
+
+    expect(option.getAttribute('data-hovered')).toBe(expectedHover);
+  });
+
   it('should not call select option when not focus to option', async () => {
     const inputRef: React.RefObject<HTMLInputElement | null> = {
       current: null,
diff --git a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx
index 36d41563e91..2e9d0e07ee3 100644
--- a/packages/vkui/src/components/CustomSelect/CustomSelect.tsx
+++ b/packages/vkui/src/components/CustomSelect/CustomSelect.tsx
@@ -5,6 +5,7 @@ import { classNames, debounce } from '@vkontakte/vkjs';
 import { useAdaptivity } from '../../hooks/useAdaptivity';
 import { useExternRef } from '../../hooks/useExternRef';
 import { useFocusWithin } from '../../hooks/useFocusWithin';
+import { useStateWithPrev } from '../../hooks/useStateWithPrev';
 import { callMultiple } from '../../lib/callMultiple';
 import { useDOM } from '../../lib/dom';
 import type { Placement } from '../../lib/floating';
@@ -140,6 +141,16 @@ const filter = <T extends CustomSelectOptionInterface>(
     : options;
 };
 
+type MousePosition = {
+  x: React.MouseEvent['clientX'];
+  y: React.MouseEvent['clientY'];
+};
+function isMousePositionChanged(event: React.MouseEvent, prevPosition: MousePosition) {
+  return (
+    Math.abs(prevPosition.x - event.clientX) >= 1 || Math.abs(prevPosition.y - event.clientY) >= 1
+  );
+}
+
 export interface CustomSelectOptionInterface {
   value: Exclude<SelectValue, null>;
   label: React.ReactElement | string;
@@ -314,35 +325,60 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
   const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(-1);
   const [isControlledOutside, setIsControlledOutside] = React.useState(props.value !== undefined);
   const [inputValue, setInputValue] = React.useState('');
-  const [nativeSelectValue, setNativeSelectValue] = React.useState<NativeSelectValue>(() => {
-    if (props.value !== undefined) {
-      return remapFromSelectValueToNativeValue(props.value);
-    }
-    if (defaultValue !== undefined) {
-      return remapFromSelectValueToNativeValue(defaultValue);
-    }
-    return NOT_SELECTED.NATIVE;
-  });
+  const [[nativeSelectValue, prevNativeSelectValue], setNativeSelectValue] =
+    useStateWithPrev<NativeSelectValue>(() => {
+      if (props.value !== undefined) {
+        return remapFromSelectValueToNativeValue(props.value);
+      }
+      if (defaultValue !== undefined) {
+        return remapFromSelectValueToNativeValue(defaultValue);
+      }
+      return NOT_SELECTED.NATIVE;
+    });
 
   const [popperPlacement, setPopperPlacement] = React.useState<Placement>(popupDirection);
-  const [options, setOptions] = React.useState(optionsProp);
+
+  const options = React.useMemo(() => {
+    return filter(optionsProp, inputValue, filterFn);
+  }, [filterFn, inputValue, optionsProp]);
+
   const [selectedOptionIndex, setSelectedOptionIndex] = React.useState<number | undefined>(
-    findSelectedIndex(optionsProp, props.value ?? defaultValue ?? null),
+    findSelectedIndex(options, props.value ?? defaultValue ?? null),
   );
 
-  React.useEffect(() => {
-    setIsControlledOutside((oldIsControlled) => {
-      const newIsControlled = props.value !== undefined;
-      checkMixControlledAndUncontrolledState(oldIsControlled, newIsControlled);
-      return newIsControlled;
-    });
-    setNativeSelectValue((nativeSelectValue) => {
+  React.useEffect(
+    function updateOptionsIndexes() {
+      const value =
+        props.value !== undefined
+          ? props.value
+          : remapFromNativeValueToSelectValue(nativeSelectValue);
+
+      const selectedIndex = findSelectedIndex(options, value);
+      setSelectedOptionIndex(selectedIndex);
+      setFocusedOptionIndex(selectedIndex);
+    },
+    [props.value, nativeSelectValue, options, filterFn],
+  );
+
+  React.useEffect(
+    function syncIsControlledState() {
+      setIsControlledOutside((oldIsControlled) => {
+        const newIsControlled = props.value !== undefined;
+        checkMixControlledAndUncontrolledState(oldIsControlled, newIsControlled);
+        return newIsControlled;
+      });
+    },
+    [props.value],
+  );
+
+  React.useEffect(
+    function syncNativeSelectValueWithPropValue() {
       if (props.value !== undefined) {
-        return remapFromSelectValueToNativeValue(props.value);
+        setNativeSelectValue(remapFromSelectValueToNativeValue(props.value));
       }
-      return nativeSelectValue;
-    });
-  }, [props.value]);
+    },
+    [props.value, setNativeSelectValue],
+  );
 
   useIsomorphicLayoutEffect(() => {
     if (
@@ -412,10 +448,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
         scrollToElement(index);
       }
 
-      // Это оптимизация, прежде всего, под `onMouseMove`
-      setFocusedOptionIndex((focusedOptionIndex) =>
-        focusedOptionIndex !== index ? index : focusedOptionIndex,
-      );
+      setFocusedOptionIndex(index);
     },
     [options, scrollToElement],
   );
@@ -467,10 +500,6 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     [keyboardInput, opened, resetFocusedOption],
   );
 
-  /**
-   * Note: сбрасывать `options` через `setOptions(optionsProp)` не нужно.
-   *  Сброс происходит в одном из эффекте `updateOptionsAndSelectedOptionIndex()`.
-   */
   const close = React.useCallback(() => {
     resetKeyboardInput();
 
@@ -496,7 +525,15 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
         selectElRef.current?.dispatchEvent(event);
       }
     },
-    [close, options, selectElRef, isControlledOutside, props.value, nativeSelectValue],
+    [
+      close,
+      options,
+      selectElRef,
+      isControlledOutside,
+      props.value,
+      nativeSelectValue,
+      setNativeSelectValue,
+    ],
   );
 
   const selectFocused = React.useCallback(() => {
@@ -554,55 +591,59 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     [focusOptionByIndex, focusedOptionIndex, options],
   );
 
-  React.useEffect(
-    function updateOptionsAndSelectedOptionIndex() {
-      const value =
-        props.value !== undefined
-          ? props.value
-          : remapFromNativeValueToSelectValue(nativeSelectValue);
-
-      const options =
-        searchable && inputValue !== undefined
-          ? filter(optionsProp, inputValue, filterFn)
-          : optionsProp;
-
-      setOptions(options);
-      const selectedIndex = findSelectedIndex(options, value);
-      setSelectedOptionIndex(selectedIndex);
-      setFocusedOptionIndex(selectedIndex);
-    },
-    [filterFn, inputValue, nativeSelectValue, optionsProp, defaultValue, props.value, searchable],
-  );
-
   const onNativeSelectChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
+    // для ситуаций, когда в опциях value это string а value/defaultValue это number
+    // и наоборот, приводим значение nativeSelectValue из стейта к строке.
+    // ведь nativeSelect всегда возвращает string в onChange, а пользователь
+    // может использовать number для опций
+    //
+    // native select всегда возвращает string в качестве value в onChange
+    // Когда селект контролируемый, то пользователь, в onChange может сохранить в свой стейт строку (например '3'), хотя
+    // в качестве value опции может использовать число (3),
+    // тогда строчное значение value ('3') из стейта пользователя
+    // будет передано в CustomSelect, и после синхронизации nativeSelectValue (3) и props.value ('3') и после клика на уже выбранную опцию (3),
+    // когда nativeSelectValue обновится на значение опции (число 3),
+    // сравнение nativeSelectValue (3) и prevNativeSelectValue ('3') может не сработать лишь из-за того, что они в разных типах.
+    const convertedNativeSelectValue =
+      typeof nativeSelectValue === 'number' &&
+      (typeof props.value === 'string' || typeof prevNativeSelectValue === 'string')
+        ? String(nativeSelectValue)
+        : nativeSelectValue;
+
+    const isCalledWithSameControlledOptionValue =
+      isControlledOutside &&
+      props.value === remapFromNativeValueToSelectValue(convertedNativeSelectValue);
+
+    const isNativeValueChanged =
+      convertedNativeSelectValue !== prevNativeSelectValue && prevNativeSelectValue !== undefined;
+
+    const isTriggeredByClearButton = allowClearButton && nativeSelectValue === NOT_SELECTED.NATIVE;
+
+    const shouldCallOnChange =
+      !isCalledWithSameControlledOptionValue && (isNativeValueChanged || isTriggeredByClearButton);
+
+    if (!shouldCallOnChange) {
+      return;
+    }
+
     const remappedNativeValue = remapFromNativeValueToSelectValue(e.currentTarget.value);
-    const newSelectedOptionIndex = findSelectedIndex(options, remappedNativeValue);
 
-    if (selectedOptionIndex !== newSelectedOptionIndex) {
-      if (!isControlledOutside) {
-        setSelectedOptionIndex(newSelectedOptionIndex);
-      }
-      if (e.target.value === NOT_SELECTED.NATIVE) {
-        e.target.value = '';
-      }
-      if (e.currentTarget.value === NOT_SELECTED.NATIVE) {
-        e.currentTarget.value = '';
-      }
-      onChange?.(e, remappedNativeValue);
+    if (e.target.value === NOT_SELECTED.NATIVE) {
+      e.target.value = '';
     }
+    if (e.currentTarget.value === NOT_SELECTED.NATIVE) {
+      e.currentTarget.value = '';
+    }
+
+    onChange?.(e, remappedNativeValue);
   };
 
   const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
     (e) => {
       onInputChangeProp && onInputChangeProp(e);
-
-      const options = filter(optionsProp, e.target.value, filterFn);
-      setOptions(options);
-      setSelectedOptionIndex(findSelectedIndex(options, nativeSelectValue));
-
       setInputValue(e.target.value);
     },
-    [filterFn, nativeSelectValue, onInputChangeProp, optionsProp],
+    [onInputChangeProp],
   );
 
   const areOptionsShown = React.useCallback(() => {
@@ -685,21 +726,12 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     [options, selectOption],
   );
 
-  const prevMousePositionRef = React.useRef<{
-    x: React.MouseEvent['clientX'];
-    y: React.MouseEvent['clientY'];
-  }>({ x: 0, y: 0 });
+  const lastMousePositionRef = React.useRef<MousePosition>({ x: 0, y: 0 });
   const focusOptionOnMouseMove = React.useCallback(
     (e: React.MouseEvent<HTMLElement>, index: number) => {
-      const isMouseChangedPosition =
-        Math.abs(prevMousePositionRef.current.x - e.clientX) >= 1 ||
-        Math.abs(prevMousePositionRef.current.y - e.clientY) >= 1;
-
-      if (isMouseChangedPosition) {
+      if (isMousePositionChanged(e, lastMousePositionRef.current)) {
         focusOptionByIndex(index, false);
       }
-
-      prevMousePositionRef.current = { x: e.clientX, y: e.clientY };
     },
     [focusOptionByIndex],
   );
@@ -788,6 +820,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     iconProp,
     restProps.disabled,
     clearButtonTestId,
+    setNativeSelectValue,
     selectInputRef,
   ]);
 
@@ -873,6 +906,22 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
 
   const focusWithin = useFocusWithin(handleRootRef);
 
+  const resetOptionFocusOnMouseLeave = React.useCallback(
+    (event: React.MouseEvent) => {
+      // В Хроме eсли мышка пользователя находится над инпутом селекта,
+      // и он с клавиатуры открывает опции, причём одна из опций
+      // уже выбрана, то видно, как выбранная опция получает фокус,
+      // но потом сразу же его теряет.
+      // Связано это с тем, что в этот момент вызывается onMouseLeave, на который у нас
+      // завязан сброс состония фокуса у опции. По хорошему фокус должен оставаться.
+      // Нам не интересен вызов onMouseLeave если мышка при этом не двигалась.
+      if (isMousePositionChanged(event, lastMousePositionRef.current)) {
+        resetFocusedOption();
+      }
+    },
+    [resetFocusedOption],
+  );
+
   return (
     <div
       className={classNames(styles.host, sizeY !== 'regular' && sizeYClassNames[sizeY], className)}
@@ -880,6 +929,9 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
       ref={handleRootRef}
       onClick={passClickAndFocusToInputOnClick}
       onMouseDown={preventInputBlurWhenClickInsideFocusedSelectArea}
+      onMouseMove={function updateLastMousePosition(e) {
+        lastMousePositionRef.current = { x: e.clientX, y: e.clientY };
+      }}
     >
       {focusWithin && selected && !opened && (
         <VisuallyHidden aria-live="polite">{selected.label}</VisuallyHidden>
@@ -934,7 +986,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
           placement={popperPlacement}
           scrollBoxRef={scrollBoxRef}
           onPlacementChange={setPopperPlacement}
-          onMouseLeave={resetFocusedOption}
+          onMouseLeave={resetOptionFocusOnMouseLeave}
           fetching={fetching}
           overscrollBehavior={overscrollBehavior}
           offsetDistance={dropdownOffsetDistance}


Reviews:
user: login: andrey-medvedev-vk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T09:46:27Z
user: login: inomdzhon
body: 🥇 

каждый кто чинит `CustomSelect` автоматом становится героем) очень сложный компонент в 1000 строк
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:37:52Z
user: login: inomdzhon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:38:32Z
user: login: andrey-medvedev-vk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T15:43:55Z
user: login: EldarMuhamethanov
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T07:29:36Z

Review comments:
diff_hunk: @@ -314,35 +315,61 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
   const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(-1);
   const [isControlledOutside, setIsControlledOutside] = React.useState(props.value !== undefined);
   const [inputValue, setInputValue] = React.useState('');
-  const [nativeSelectValue, setNativeSelectValue] = React.useState<NativeSelectValue>(() => {
-    if (props.value !== undefined) {
-      return remapFromSelectValueToNativeValue(props.value);
-    }
-    if (defaultValue !== undefined) {
-      return remapFromSelectValueToNativeValue(defaultValue);
-    }
-    return NOT_SELECTED.NATIVE;
-  });
+  const [[nativeSelectValue, prevNativeSelectValue], setNativeSelectValue] =
+    useStateWithPrev<NativeSelectValue>(() => {
+      if (props.value !== undefined) {
+        return remapFromSelectValueToNativeValue(props.value);
+      }
+      if (defaultValue !== undefined) {
+        return remapFromSelectValueToNativeValue(defaultValue);
+      }
+      return NOT_SELECTED.NATIVE;
+    });
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Чтобы знать следует ли вызвать `props.onChange` нам нужно предыдущее значение `nativeSelectValue`. Для этого мы используем хук `useStateWithPrev`
created_at: 2025-03-14T07:42:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -314,35 +315,61 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
   const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(-1);
   const [isControlledOutside, setIsControlledOutside] = React.useState(props.value !== undefined);
   const [inputValue, setInputValue] = React.useState('');
-  const [nativeSelectValue, setNativeSelectValue] = React.useState<NativeSelectValue>(() => {
-    if (props.value !== undefined) {
-      return remapFromSelectValueToNativeValue(props.value);
-    }
-    if (defaultValue !== undefined) {
-      return remapFromSelectValueToNativeValue(defaultValue);
-    }
-    return NOT_SELECTED.NATIVE;
-  });
+  const [[nativeSelectValue, prevNativeSelectValue], setNativeSelectValue] =
+    useStateWithPrev<NativeSelectValue>(() => {
+      if (props.value !== undefined) {
+        return remapFromSelectValueToNativeValue(props.value);
+      }
+      if (defaultValue !== undefined) {
+        return remapFromSelectValueToNativeValue(defaultValue);
+      }
+      return NOT_SELECTED.NATIVE;
+    });
 
   const [popperPlacement, setPopperPlacement] = React.useState<Placement>(popupDirection);
-  const [options, setOptions] = React.useState(optionsProp);
+
+  const options = React.useMemo(() => {
+    return filter(optionsProp, inputValue, filterFn);
+  }, [filterFn, inputValue, optionsProp]);
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Вместо стейта локальные, отфильтрованные опции (`options`) теперь вычисляются через useMemo`.`
created_at: 2025-03-14T07:43:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -412,10 +439,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
         scrollToElement(index);
       }
 
-      // Это оптимизация, прежде всего, под `onMouseMove`
-      setFocusedOptionIndex((focusedOptionIndex) =>
-        focusedOptionIndex !== index ? index : focusedOptionIndex,
-      );
+      setFocusedOptionIndex(index);
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Проследил где это появилось. Довольно старая реализация, ещё на классах.
Кажется, сейчас в этом нету смысла.
created_at: 2025-03-14T08:02:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -314,35 +315,61 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
   const [focusedOptionIndex, setFocusedOptionIndex] = React.useState<number | undefined>(-1);
   const [isControlledOutside, setIsControlledOutside] = React.useState(props.value !== undefined);
   const [inputValue, setInputValue] = React.useState('');
-  const [nativeSelectValue, setNativeSelectValue] = React.useState<NativeSelectValue>(() => {
-    if (props.value !== undefined) {
-      return remapFromSelectValueToNativeValue(props.value);
-    }
-    if (defaultValue !== undefined) {
-      return remapFromSelectValueToNativeValue(defaultValue);
-    }
-    return NOT_SELECTED.NATIVE;
-  });
+  const [[nativeSelectValue, prevNativeSelectValue], setNativeSelectValue] =
+    useStateWithPrev<NativeSelectValue>(() => {
+      if (props.value !== undefined) {
+        return remapFromSelectValueToNativeValue(props.value);
+      }
+      if (defaultValue !== undefined) {
+        return remapFromSelectValueToNativeValue(defaultValue);
+      }
+      return NOT_SELECTED.NATIVE;
+    });
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Правда, раз это кастомный хук, то теперь `setNativeSelectValue` пришлось добавить во все хуки как зависимость.
created_at: 2025-03-14T08:04:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -554,55 +582,59 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     [focusOptionByIndex, focusedOptionIndex, options],
   );
 
-  React.useEffect(
-    function updateOptionsAndSelectedOptionIndex() {
-      const value =
-        props.value !== undefined
-          ? props.value
-          : remapFromNativeValueToSelectValue(nativeSelectValue);
-
-      const options =
-        searchable && inputValue !== undefined
-          ? filter(optionsProp, inputValue, filterFn)
-          : optionsProp;
-
-      setOptions(options);
-      const selectedIndex = findSelectedIndex(options, value);
-      setSelectedOptionIndex(selectedIndex);
-      setFocusedOptionIndex(selectedIndex);
-    },
-    [filterFn, inputValue, nativeSelectValue, optionsProp, defaultValue, props.value, searchable],
-  );
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: 
Хук перенёс чуть выше.
Вычислять и фильтровать `options` больше не нужно.
https://github.com/VKCOM/VKUI/blob/1202e04b2b7f0d1800009689a50c3262010d17da/packages/vkui/src/components/CustomSelect/CustomSelect.tsx#L342-L349
created_at: 2025-03-14T08:08:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -554,55 +582,59 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
     [focusOptionByIndex, focusedOptionIndex, options],
   );
 
-  React.useEffect(
-    function updateOptionsAndSelectedOptionIndex() {
-      const value =
-        props.value !== undefined
-          ? props.value
-          : remapFromNativeValueToSelectValue(nativeSelectValue);
-
-      const options =
-        searchable && inputValue !== undefined
-          ? filter(optionsProp, inputValue, filterFn)
-          : optionsProp;
-
-      setOptions(options);
-      const selectedIndex = findSelectedIndex(options, value);
-      setSelectedOptionIndex(selectedIndex);
-      setFocusedOptionIndex(selectedIndex);
-    },
-    [filterFn, inputValue, nativeSelectValue, optionsProp, defaultValue, props.value, searchable],
-  );
-
   const onNativeSelectChange: React.ChangeEventHandler<HTMLSelectElement> = (e) => {
+    // для ситуаций, когда в опциях value это string а value/defaultValue это number
+    // и наоборот, приводим значение nativeSelectValue из стейта к строке.
+    // ведь nativeSelect всегда возвращает string в onChange, а пользователь
+    // может использовать number для опций
+    //
+    // native select всегда возвращает string в качестве value в onChange
+    // Когда селект контролируемый, то пользователь, в onChange может сохранить в свой стейт строку (например '3'), хотя
+    // в качестве value опции может использовать число (3),
+    // тогда строчное значение value ('3') из стейта пользователя
+    // будет передано в CustomSelect, и после синхронизации nativeSelectValue (3) и props.value ('3') и после клика на уже выбранную опцию (3),
+    // когда nativeSelectValue обновится на значение опции (число 3),
+    // сравнение nativeSelectValue (3) и prevNativeSelectValue ('3') может не сработать лишь из-за того, что они в разных типах.
+    const convertedNativeSelectValue =
+      typeof nativeSelectValue === 'number' &&
+      (typeof props.value === 'string' || typeof prevNativeSelectValue === 'string')
+        ? String(nativeSelectValue)
+        : nativeSelectValue;
+
+    const isCalledWithSameControlledOptionValue =
+      isControlledOutside &&
+      props.value === remapFromNativeValueToSelectValue(convertedNativeSelectValue);
+
+    const isNativeValueChanged =
+      convertedNativeSelectValue !== prevNativeSelectValue && prevNativeSelectValue !== undefined;
+
+    const isTriggeredByClearButton = allowClearButton && nativeSelectValue === NOT_SELECTED.NATIVE;
+
+    const shouldCallOnChange =
+      !isCalledWithSameControlledOptionValue && (isNativeValueChanged || isTriggeredByClearButton);
+
+    if (!shouldCallOnChange) {
+      return;
+    }
+
     const remappedNativeValue = remapFromNativeValueToSelectValue(e.currentTarget.value);
-    const newSelectedOptionIndex = findSelectedIndex(options, remappedNativeValue);
 
-    if (selectedOptionIndex !== newSelectedOptionIndex) {
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Обратная сторона медали вычисления `options` при каждом рендере в том, что нельзя положиться на `selectedOptionIndex` при обработке `onChange` у нативного селекта. Так как options уже к этому моменту могут отфильтроваться и `selectedOptionIndex` может указывать не на ту опцию.
Поэтому пришлось пологаться на значение `nativeSelectValue`, а оно может быть как строкой, так и числом. Причём у нативного селекта `value` это всегда строка.
В итоге приходится проверять тип и приводить к строке `nativeSelectValue` при необходимости, чтобы  правильно сделать сравнение `nativeSelect` и `prevNativeSelect`, для принятия решения о вызове `onChange`.

Чтобы было понятно.
Пример.

У нас есть 
```tsx
<CustomSelect
  options={[{value: 1, label: 'Категория 1'}, {value: 2, label: 'Категория 2'}]}
  defaultValue="1"
/>
```

`defaultValue` тут строка "1", но `value` у опций это `number`
`nativeSelectValue` сейчас это строка "1".
При клике по опции "Категория 1" мы назначаем `nativeSelectValue` число 1,
В итоге в стейте мы имеем prevNativeSelectValue = "1" (строка), а nativeSelectValue = 1 число.
Строгое сравнение не пройдёт и если тип не проверять, то мы вызовим `props.onChange`, хотя не должны.
То же верно и наоборот. `defaultValue` может быть числом, а `value` у опций могут быть строки.
created_at: 2025-03-14T08:21:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -412,10 +439,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
         scrollToElement(index);
       }
 
-      // Это оптимизация, прежде всего, под `onMouseMove`
-      setFocusedOptionIndex((focusedOptionIndex) =>
-        focusedOptionIndex !== index ? index : focusedOptionIndex,
-      );
+      setFocusedOptionIndex(index);
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: inomdzhon
body: Вот такая бага была https://github.com/VKCOM/VKUI/issues/2315, проверил, что не воспроизводится 👍 
created_at: 2025-03-20T15:38:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -412,10 +439,7 @@ export function CustomSelect<OptionInterfaceT extends CustomSelectOptionInterfac
         scrollToElement(index);
       }
 
-      // Это оптимизация, прежде всего, под `onMouseMove`
-      setFocusedOptionIndex((focusedOptionIndex) =>
-        focusedOptionIndex !== index ? index : focusedOptionIndex,
-      );
+      setFocusedOptionIndex(index);
path: packages/vkui/src/components/CustomSelect/CustomSelect.tsx
user: login: andrey-medvedev-vk
body: Да, тоже нашел и проверял, забыл только отписаться и ссылку добавить. Спасибо за ссылку!
created_at: 2025-03-20T15:43:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: codesandbox-ci[bot]
created_at: 2025-03-13T15:11:46Z
body: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/VKCOM/VKUI/pr/8365/builds/598305) or the icon next to each commit SHA.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-13T15:12:10Z
body: ## size-limit report 📦 
| Path                         | Size                  |
| ---------------------------- | --------------------- |
| JS                           | 398.6 KB (+0.05% 🔺)  |
| JS (gzip)                    | 120.93 KB (+0.05% 🔺) |
| JS (brotli)                  | 99.45 KB (+0.06% 🔺)  |
| JS import Div (tree shaking) | 1.56 KB (0%)          |
| CSS                          | 348.96 KB (0%)        |
| CSS (gzip)                   | 43.23 KB (0%)         |
| CSS (brotli)                 | 34.45 KB (0%)         |
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-13T15:21:11Z
body: <!--GitHub Comment Builder-->
## e2e tests

 <a href="https://vkui-screenshot.hb.bizmrg.com/pull/8365/806e079b113849824e0354f7dff03bc9399efc76/playwright-report/index.html" target="_blank">Playwright Report</a>


author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-13T15:21:34Z
body: <!-- docs_urls -->
## 👀 Docs deployed

- ✅ [Styleguide](https://vkui-screenshot.hb.bizmrg.com/pull/8365/806e079b113849824e0354f7dff03bc9399efc76/styleguide/index.html)
- ✅ [Storybook](https://vkui-screenshot.hb.bizmrg.com/pull/8365/806e079b113849824e0354f7dff03bc9399efc76/storybook/index.html)
- ✅ [Docs beta](https://vkui-screenshot.hb.bizmrg.com/pull/8365/806e079b113849824e0354f7dff03bc9399efc76/docs-beta/overview/about.html)

Commit 806e079b113849824e0354f7dff03bc9399efc76
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-03-13T15:21:34Z
body: ## [Codecov](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) Report
Attention: Patch coverage is `97.77778%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 95.42%. Comparing base [(`a5681eb`)](https://app.codecov.io/gh/VKCOM/VKUI/commit/a5681eb7c15b1279d2b5339a58b2d84810f17984?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) to head [(`1202e04`)](https://app.codecov.io/gh/VKCOM/VKUI/commit/1202e04b2b7f0d1800009689a50c3262010d17da?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).

| [Files with missing lines](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) | Patch % | Lines |
|---|---|---|
| [.../vkui/src/components/CustomSelect/CustomSelect.tsx](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365?src=pr&el=tree&filepath=packages%2Fvkui%2Fsrc%2Fcomponents%2FCustomSelect%2FCustomSelect.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM#diff-cGFja2FnZXMvdmt1aS9zcmMvY29tcG9uZW50cy9DdXN0b21TZWxlY3QvQ3VzdG9tU2VsZWN0LnRzeA==) | 97.77% | [1 Missing :warning: ](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) |

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #8365   +/-   ##
=======================================
  Coverage   95.42%   95.42%           
=======================================
  Files         409      409           
  Lines       11649    11651    +2     
  Branches     3859     3862    +3     
=======================================
+ Hits        11116    11118    +2     
  Misses        533      533           
```

| [Flag](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM) | `95.42% <97.77%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/VKCOM/VKUI/pull/8365?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=VKCOM).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- 📦 [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: BlackySoul
created_at: 2025-03-14T10:45:59Z
body: 
https://github.com/user-attachments/assets/98cdb645-694c-4090-8c96-58dd929f314c

Такую штуку заметила, что при повторном поиске ранее выбранной опции она на некоторое время подсвечивается и потом сбрасывается 🤔 
author_association: CONTRIBUTOR
user: login: andrey-medvedev-vk
created_at: 2025-03-14T10:59:02Z
body: > 2025-03-14.17.43.21.mov
> 
> Такую штуку заметила, что при повторном поиске ранее выбранной опции она на некоторое время подсвечивается и потом сбрасывается 🤔

О, интересно, спасибо, посмотрю! Тоже, видимо с обновлением индексов дело. Пока переведу в драфт.
author_association: CONTRIBUTOR
user: login: andrey-medvedev-vk
created_at: 2025-03-18T09:50:36Z
body: > 2025-03-14.17.43.21.mov
> 
> Такую штуку заметила, что при повторном поиске ранее выбранной опции она на некоторое время подсвечивается и потом сбрасывается 🤔

@BlackySoul 
Оказывается это воспроизводится и у нас в мастере. Причем в Хроме воспроизводится, а в Фаерфоксе это не поймать.
Шаги для воспроизведения.
1. Выбрать опцию из списка,
2. Переместить мышку на инпут
3. Открыть дропдуан с клавиатуры (в примере выше путём ввода текста)
4. Появится список и выбранная опция будет в фокусе, а потом фокус пропадёт.

Связано с тем, что когда у нас мышка на инпуте, то после открытия дропдауна тригерится событие `onMouseLeave` причём `target` у него это та самая опция, бывшая в фокусе, а `relatedTarget` (target на который курсор перешёл) это инпут. А на `onMouseLeave` у нас завязана логика подсветки элемента в фокусе (программный ховер).

Поправил, с помощью проверки, что мышка не двигалась в момент события `onMouseLeave`.
⚡ https://github.com/VKCOM/VKUI/compare/40d3f88956a147626b1b3de39087090f6f64a84d%5E1...mendrew/CustomSelect/8334/fix-options-synchronization
author_association: CONTRIBUTOR