Repository: grommet/grommet (JavaScript)

Issue:
Unable to select a dynamically loaded option from Select Field
<!--- Provide a general summary of the issue in the Title above -->
Unable to select a dynamically loaded option from Select Field. 
In a Select component, where we lazy load more options, we are unable to select an option from the lazy loaded options.

### Expected Behavior
We should be able to select any option from the Select Component
<!--- Tell us what should happen -->

### Actual Behavior
We are unable to select an option from the latest list of options(load more)
https://storybook.grommet.io/?path=/story/input-select--lazy-loading-options
Unable to select the options after the Option 199 in this story book.

<!--- Tell us what happens instead -->

### URL, screen shot, or Codepen exhibiting the issue
https://storybook.grommet.io/?path=/story/input-select--lazy-loading-options

<!--
  -- Here's a Codesandbox template that serves as a nice starting point
  -- for demonstrating an issue: https://codesandbox.io/s/m7mml8l0zj
  -->

### Steps to Reproduce

Open https://storybook.grommet.io/?path=/story/input-select--lazy-loading-options
Scroll to Option 200
Select Option 200, it is not selected, if we close and open it, the option is selected but the selected value is not displayed in the Select Field.

### Your Environment

<!--- Include as many relevant details about the environment you experienced the bug in -->

- Grommet version: 2.16.0
- Browser Name and version: chrome
- Operating System and version (desktop or mobile): Mac, Windows

Dynamically loaded options and value are not shown in Select component
<!--- Provide a general summary of the issue in the Title above -->

### Expected Behavior
If the user dynamically passes the options and a value to a Select component, then the value passed should be shown as selected in the component.

<!--- Tell us what should happen -->

### Actual Behavior
The component is not showing the passed value as selected.
It looks like a regression. This behavior is achievable in grommet version: 2.15.2

<!--- Tell us what happens instead -->

### URL, screen shot, or Codepen exhibiting the issue
Please check this code box example. (using Grommet version: 2.15.2) **Working as expected**
https://codesandbox.io/s/snowy-haze-bpe4j?file=/src/App.js

This is the same example with Grommet version 2.16.0  **Not working**
https://codesandbox.io/s/adoring-visvesvaraya-5piwx?file=/src/App.js

<!--
  -- Here's a Codesandbox template that serves as a nice starting point
  -- for demonstrating an issue: https://codesandbox.io/s/m7mml8l0zj
  -->

### Your Environment

<!--- Include as many relevant details about the environment you experienced the bug in -->

- Grommet version: 2.16.0
- Browser Name and version: Chrome
- Operating System and version (desktop or mobile): Desktop

Broken Select with async options
Commit https://github.com/grommet/grommet/commit/98368af6395a3f6e36fab23c111b5d517cffc9d3 has broken select with async options

When a select is rendered with options populated asynchronously (ie: after initial render), the selected value is not rendered in the input (it does show as selected in the option layer when it is opened)

### Expected Behavior

Select should behave as it did before this commit, where options that are updated AFTER the initial render will still render the selected value properly.

### Actual Behavior

Selected Value does not render if options are updated after initial render.

### URL, screen shot, or Codepen exhibiting the issue

https://codesandbox.io/s/grommet-v2-template-forked-nqvql?file=/index.js

### Steps to Reproduce

1. Render a <Select /> element with options that are NOT populated at initial render
2. Notice that the selected value is not displayed properly

### Your Environment

- Grommet version: 2.16.0
- Browser Name and version: All
- Operating System and version (desktop or mobile): All



Discussion:
Thanks for reporting, we'll prioritize the issue. Can you confirm the behavior was working as expected on v2.15.2?
> Thanks for reporting, we'll prioritize the issue. Can you confirm the behavior was working as expected on v2.15.2?

We did confirm inside our UI that the problem did not show up if we added a resolution that forced use of the v2.15.2 grommet version.
FYI, I did some initial investigation to track down where this regression was introduced.

Using git bisect (starting on the master branch) as follows:
```
git bisect start
git bisect bad
git bisect good v2.15.2
```

Identified https://github.com/grommet/grommet/pull/4778 as the commit which introduced the regression. 

![Screen Shot 2020-12-03 at 6 58 23 PM](https://user-images.githubusercontent.com/1756948/101389211-50595a80-387e-11eb-9b2c-a34f142a1cd4.png)

I haven't researched beyond here, but the description in the PR "The problem was that options change when they are filtered using search. So indexes of selected values are invalid. Now we always use the initial list of options, so the indexes will always be valid." sounds as if the approach taken may not work in scenarios where the options are loaded using InfiniteScroll's onMore.
@romanalesenkov1 FYI ⬆️ 
Just for more information. We had seen the issue too in our UI if we just render the select component before the options are fully loaded. So it's not only an issue with onMore maybe but a more general loss of binding if the options change after render.
Thanks for sharing @sabino-gadaleta , we are prioritizing this issue and we'll release a hotfix to include a fix asap.
As a workaround it is possible to re-render select when options changed :

![image](https://user-images.githubusercontent.com/13457738/101407273-b203de00-38eb-11eb-93c4-c7c5d74d1ac1.png)

At a first glance I didn't find the way to fix the problem without breaking changes.

It looks like we should make onChange function clear function: 
1. do not set filtered options in this function
2. return filtered options from
3. use this function in select to filter options

 ---------------
It'll also be great to write tests for scenarios: 
1. search+multiple select 
2. lazy load more options and select
I have written tests for:
1. search+multiple select
1. lazy load more options and select

Will be working on fix today.



Related https://github.com/grommet/grommet/issues/4792



Patch:
diff --git a/src/js/components/Select/Select.js b/src/js/components/Select/Select.js
index 2f423015910..a478eeab5e1 100644
--- a/src/js/components/Select/Select.js
+++ b/src/js/components/Select/Select.js
@@ -73,7 +73,7 @@ const Select = forwardRef(
       onOpen,
       onSearch,
       open: propOpen,
-      options,
+      options: optionsProp,
       placeholder,
       plain,
       replace,
@@ -101,12 +101,21 @@ const Select = forwardRef(
         );
       return valueKey && valueKey.reduce ? value : applyKey(value, valueKey);
     }, [value, valueKey]);
+    // search input value
+    const [search, setSearch] = useState();
+    // All select option indices and values
+    const [allOptions, setAllOptions] = useState(optionsProp);
+    // Track changes to options property, except when options are being
+    // updated due to search activity. Allows option's initial index value
+    // to be referenced when filtered by search.
+    useEffect(() => {
+      if (!search) setAllOptions(optionsProp);
+    }, [optionsProp, search]);
 
-    const [initialOptions] = useState(options);
     // the option indexes present in the value
     const optionIndexesInValue = useMemo(() => {
       const result = [];
-      initialOptions.forEach((option, index) => {
+      allOptions.forEach((option, index) => {
         if (selected !== undefined) {
           if (Array.isArray(selected)) {
             if (selected.indexOf(index) !== -1) result.push(index);
@@ -122,7 +131,7 @@ const Select = forwardRef(
         }
       });
       return result;
-    }, [initialOptions, selected, valueKey, valuedValue]);
+    }, [allOptions, selected, valueKey, valuedValue]);
 
     const [open, setOpen] = useState(propOpen);
     useEffect(() => setOpen(propOpen), [propOpen]);
@@ -151,6 +160,7 @@ const Select = forwardRef(
           adjustedEvent.selected = nextSelected;
           onChange(adjustedEvent);
         }
+        setSearch();
       },
       [closeOnChange, onChange, onRequestClose, setValue],
     );
@@ -182,11 +192,11 @@ const Select = forwardRef(
       if (!selectValue) {
         if (optionIndexesInValue.length === 0) return '';
         if (optionIndexesInValue.length === 1)
-          return applyKey(initialOptions[optionIndexesInValue[0]], labelKey);
+          return applyKey(allOptions[optionIndexesInValue[0]], labelKey);
         return messages.multiple;
       }
       return undefined;
-    }, [labelKey, messages, optionIndexesInValue, initialOptions, selectValue]);
+    }, [labelKey, messages, optionIndexesInValue, allOptions, selectValue]);
 
     const iconColor = normalizeColor(
       theme.select.icons.color || 'control',
@@ -224,10 +234,13 @@ const Select = forwardRef(
               onKeyDown={onKeyDown}
               onMore={onMore}
               onSearch={onSearch}
-              options={options}
+              options={optionsProp}
+              allOptions={allOptions}
               optionIndexesInValue={optionIndexesInValue}
               replace={replace}
               searchPlaceholder={searchPlaceholder}
+              search={search}
+              setSearch={setSearch}
               selected={selected}
               value={value}
               valueKey={valueKey}
diff --git a/src/js/components/Select/SelectContainer.js b/src/js/components/Select/SelectContainer.js
index f81d028fc6b..78579ecf419 100644
--- a/src/js/components/Select/SelectContainer.js
+++ b/src/js/components/Select/SelectContainer.js
@@ -74,8 +74,11 @@ const SelectContainer = forwardRef(
       onMore,
       onSearch,
       optionIndexesInValue,
-      options,
+      options: optionsProp,
+      allOptions,
       searchPlaceholder,
+      search,
+      setSearch,
       selected,
       value = '',
       valueKey,
@@ -84,11 +87,9 @@ const SelectContainer = forwardRef(
     ref,
   ) => {
     const theme = useContext(ThemeContext) || defaultProps.theme;
-    const [search, setSearch] = useState();
     const [activeIndex, setActiveIndex] = useState(-1);
     const [keyboardNavigation, setKeyboardNavigation] = useState();
     const [focus, setFocus] = useState(false);
-    const [initialOptions] = useState(options);
     const searchRef = useRef();
     const optionsRef = useRef();
     // adjust activeIndex when options change
@@ -126,18 +127,18 @@ const SelectContainer = forwardRef(
     }, [keyboardNavigation]);
 
     const optionLabel = useCallback(
-      index => applyKey(options[index], labelKey),
-      [labelKey, options],
+      index => applyKey(optionsProp[index], labelKey),
+      [labelKey, optionsProp],
     );
 
     const optionValue = useCallback(
-      index => applyKey(options[index], valueKey),
-      [options, valueKey],
+      index => applyKey(optionsProp[index], valueKey),
+      [optionsProp, valueKey],
     );
 
     const isDisabled = useCallback(
       index => {
-        const option = options[index];
+        const option = optionsProp[index];
         let result;
         if (disabledKey) {
           result = applyKey(option, disabledKey);
@@ -151,7 +152,7 @@ const SelectContainer = forwardRef(
         }
         return result;
       },
-      [disabled, disabledKey, options, optionValue],
+      [disabled, disabledKey, optionsProp, optionValue],
     );
 
     const isSelected = useCallback(
@@ -198,30 +199,28 @@ const SelectContainer = forwardRef(
           let nextSelected;
           if (multiple) {
             const nextOptionIndexesInValue = optionIndexesInValue.slice(0);
-            const initialOptionsIndex = initialOptions.indexOf(options[index]);
-            const valueIndex = optionIndexesInValue.indexOf(
-              initialOptionsIndex,
-            );
+            const allOptionsIndex = allOptions.indexOf(optionsProp[index]);
+            const valueIndex = optionIndexesInValue.indexOf(allOptionsIndex);
             if (valueIndex === -1) {
-              nextOptionIndexesInValue.push(initialOptionsIndex);
+              nextOptionIndexesInValue.push(allOptionsIndex);
             } else {
               nextOptionIndexesInValue.splice(valueIndex, 1);
             }
             nextValue = nextOptionIndexesInValue.map(i =>
               valueKey && valueKey.reduce
-                ? applyKey(initialOptions[i], valueKey)
-                : initialOptions[i],
+                ? applyKey(allOptions[i], valueKey)
+                : allOptions[i],
             );
             nextSelected = nextOptionIndexesInValue;
           } else {
             nextValue =
               valueKey && valueKey.reduce
-                ? applyKey(options[index], valueKey)
-                : options[index];
+                ? applyKey(allOptions[index], valueKey)
+                : allOptions[index];
             nextSelected = index;
           }
           onChange(event, {
-            option: options[index],
+            option: allOptions[index],
             value: nextValue,
             selected: nextSelected,
           });
@@ -231,8 +230,8 @@ const SelectContainer = forwardRef(
         multiple,
         onChange,
         optionIndexesInValue,
-        initialOptions,
-        options,
+        optionsProp,
+        allOptions,
         valueKey,
       ],
     );
@@ -249,17 +248,17 @@ const SelectContainer = forwardRef(
         event.preventDefault();
         let nextActiveIndex = activeIndex + 1;
         while (
-          nextActiveIndex < options.length &&
+          nextActiveIndex < optionsProp.length &&
           isDisabled(nextActiveIndex)
         ) {
           nextActiveIndex += 1;
         }
-        if (nextActiveIndex !== options.length) {
+        if (nextActiveIndex !== optionsProp.length) {
           setActiveIndex(nextActiveIndex);
           setKeyboardNavigation(true);
         }
       },
-      [activeIndex, isDisabled, options],
+      [activeIndex, isDisabled, optionsProp],
     );
 
     const onPreviousOption = useCallback(
@@ -344,9 +343,9 @@ const SelectContainer = forwardRef(
             />
           )}
           <OptionsBox role="menubar" tabIndex="-1" ref={optionsRef}>
-            {options.length > 0 ? (
+            {optionsProp.length > 0 ? (
               <InfiniteScroll
-                items={options}
+                items={optionsProp}
                 step={theme.select.step}
                 onMore={onMore}
                 replace={replace}
@@ -360,7 +359,7 @@ const SelectContainer = forwardRef(
                   // as an option Button kind property.
                   let child;
                   if (children)
-                    child = children(option, index, options, {
+                    child = children(option, index, optionsProp, {
                       active: optionActive,
                       disabled: optionDisabled,
                       selected: optionSelected,
diff --git a/src/js/components/Select/stories/LazyLoadingOptions.js b/src/js/components/Select/stories/LazyLoadingOptions.js
index 13e39e54acb..ede3168230b 100644
--- a/src/js/components/Select/stories/LazyLoadingOptions.js
+++ b/src/js/components/Select/stories/LazyLoadingOptions.js
@@ -25,7 +25,6 @@ export const LazyLoadingOptions = () => {
 
   const onMore = () => {
     setTimeout(() => {
-      console.log('onmore called');
       setOptions(dummyOptions.slice(0, options.length + 200));
     }, 1000);
   };


Test patch:
diff --git a/src/js/components/Select/__tests__/SelectMultiple-test.js b/src/js/components/Select/__tests__/SelectMultiple-test.js
index 2ca10158045..dd03727c2e2 100644
--- a/src/js/components/Select/__tests__/SelectMultiple-test.js
+++ b/src/js/components/Select/__tests__/SelectMultiple-test.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import 'jest-styled-components';
 import renderer from 'react-test-renderer';
-import { cleanup, render, fireEvent } from '@testing-library/react';
+import { act, cleanup, render, fireEvent } from '@testing-library/react';
 import { axe } from 'jest-axe';
 import 'jest-axe/extend-expect';
 import 'regenerator-runtime/runtime';
@@ -319,5 +319,182 @@ describe('Select Controlled', () => {
     fireEvent.click(getByPlaceholderText('test select'));
     expectPortal('test-select__drop').toMatchSnapshot();
   });
+
+  test('should allow multiple selections when using search', () => {
+    jest.useFakeTimers();
+    const onChange = jest.fn();
+    const onSearch = jest.fn();
+    const defaultOptions = [
+      {
+        id: 1,
+        name: 'Value1',
+      },
+      {
+        id: 2,
+        name: 'Value2',
+      },
+      {
+        id: 15,
+        name: 'Value15',
+      },
+      {
+        id: 21,
+        name: 'Value21',
+      },
+      {
+        id: 22,
+        name: 'Value22',
+      },
+    ];
+
+    const Test = () => {
+      const [value, setValue] = React.useState();
+      const [options, setOptions] = React.useState(defaultOptions);
+
+      return (
+        <Select
+          id="test-select-mult-search"
+          placeholder="Select multiple options"
+          multiple
+          valueKey="id"
+          labelKey="name"
+          value={value}
+          options={options}
+          onChange={({ value: nextValue }) => {
+            onChange(nextValue);
+            setValue(nextValue);
+          }}
+          onClose={() => setOptions(defaultOptions)}
+          onSearch={text => {
+            onSearch(text);
+            const nextOptions = defaultOptions.filter(
+              option =>
+                option.name.toLowerCase().indexOf(text.toLowerCase()) >= 0,
+            );
+            setOptions(nextOptions);
+          }}
+        />
+      );
+    };
+    const { getByPlaceholderText, getByText } = render(
+      <Grommet>
+        <Test />
+      </Grommet>,
+    );
+
+    const selectInput = getByPlaceholderText('Select multiple options');
+    fireEvent.click(selectInput);
+    // advance timers so select can open & have focus
+    act(() => {
+      jest.advanceTimersByTime(200);
+    });
+
+    // focus is on search input, searching...
+    fireEvent.change(document.activeElement, { target: { value: '1' } });
+    expect(onSearch).toHaveBeenNthCalledWith(1, '1');
+    // make first selection
+    fireEvent.click(getByText('Value15'));
+    fireEvent.click(selectInput);
+    act(() => {
+      jest.advanceTimersByTime(200);
+    });
+
+    // searching again
+    fireEvent.change(document.activeElement, { target: { value: '2' } });
+    expect(onSearch).toHaveBeenNthCalledWith(2, '2');
+    // make second selection
+    fireEvent.click(getByText('Value21'));
+    expect(onChange).toHaveBeenNthCalledWith(2, [
+      { id: 15, name: 'Value15' },
+      { id: 21, name: 'Value21' },
+    ]);
+
+    fireEvent.click(selectInput);
+    act(() => {
+      jest.advanceTimersByTime(200);
+    });
+    // remove previous selection
+    fireEvent.click(getByText('Value15'));
+    expect(onChange).toHaveBeenNthCalledWith(3, [{ id: 21, name: 'Value21' }]);
+  });
+
+  test(`should allow multiple selections when options are 
+  loaded lazily`, () => {
+    jest.useFakeTimers();
+    const onChange = jest.fn();
+    const optionsFromServer = [
+      {
+        id: 1,
+        name: 'Value1',
+      },
+      {
+        id: 2,
+        name: 'Value2',
+      },
+      {
+        id: 15,
+        name: 'Value15',
+      },
+      {
+        id: 21,
+        name: 'Value21',
+      },
+      {
+        id: 22,
+        name: 'Value22',
+      },
+    ];
+
+    const Test = () => {
+      const [value, setValue] = React.useState();
+      const [options, setOptions] = React.useState([]);
+
+      // get options from mock server
+      React.useEffect(() => {
+        setTimeout(() => {
+          setOptions(optionsFromServer);
+        }, 1000);
+      }, []);
+
+      return (
+        <Select
+          id="test-select-mult-lazy"
+          placeholder="Select multiple lazyload options"
+          multiple
+          valueKey="id"
+          labelKey="name"
+          value={value}
+          options={options}
+          onChange={({ value: nextValue }) => {
+            onChange(nextValue);
+            setValue(nextValue);
+          }}
+        />
+      );
+    };
+    const { getByPlaceholderText, getByText } = render(
+      <Grommet>
+        <Test />
+      </Grommet>,
+    );
+
+    const selectInput = getByPlaceholderText(
+      'Select multiple lazyload options',
+    );
+    fireEvent.click(selectInput);
+    // advance timers so that options have been returned
+    act(() => {
+      jest.advanceTimersByTime(1100);
+    });
+    fireEvent.click(getByText('Value15'));
+    expect(onChange).toHaveBeenNthCalledWith(1, [{ id: 15, name: 'Value15' }]);
+    fireEvent.click(selectInput);
+    fireEvent.click(getByText('Value22'));
+    expect(onChange).toHaveBeenNthCalledWith(2, [
+      { id: 15, name: 'Value15' },
+      { id: 22, name: 'Value22' },
+    ]);
+  });
+
   window.scrollTo.mockRestore();
 });