The Wayback Machine - https://web.archive.org/web/20201019210027/https://github.com/mui-org/material-ui/issues/22170
Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autocomplete] onHighlightChange returns wrong option when options are set asychronously #22170

Open
herisn23 opened this issue Aug 12, 2020 · 11 comments

Comments

@herisn23
Copy link

@herisn23 herisn23 commented Aug 12, 2020

  • The issue is present in the latest release.
  • I have searched the issues of this repository and believe that this is not a duplicate.

Current Behavior 馃槸

Disabled filtering filterOptions={x=>x}
Use autoHighlight then change options asynchronously. Callback onHighlightChange provide option from previous options after options are set instead of new one.

Expected Behavior 馃

Callback onHighlightChange should provide auto highlighted option from latest provided options instead of previous options.

Steps to Reproduce 馃暪

  1. Open https://codesandbox.io/s/jolly-benz-uz6mw?file=/demo.tsx
  2. Type 1 in textfield. Autocomplete display options filtered by input value and callback onHighlightChange log correct value option 1 1
  3. Type 2 in textfield. Autocomplete display options filtered by input value and callback onHighlightChange log wrong value option 1 1 instead of option 2 12

Your Environment 馃寧

Tech Version
Material-UI v4.11.0
React 16.13.1
Browser Chrome
TypeScript 3.9.7
etc. "@material-ui/lab": "^4.0.0-alpha.56"
@mnajdova
Copy link
Member

@mnajdova mnajdova commented Sep 14, 2020

After some investigation it seems to me that we should invoke the syncHighlightedIndex also if the filteredOptions changes, not just if the length changes, so we would be able to catch these async changes.

How could we solve this?

First, we would need to change the dependency list of this callback, to contain the filteredOptions, not just it's length.

diff --git a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
index a9d8d48de..f4439ca55 100644
--- a/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui-lab/src/useAutocomplete/useAutocomplete.js
@@ -464,9 +464,7 @@ export default function useAutocomplete(props) {
     // Ignore filteredOptions (and options, getOptionSelected, getOptionLabel) not to break the scroll position
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [
-    // Only sync the highlighted index when the option switch between empty and not
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    filteredOptions.length === 0,
+    filteredOptions,
     // Don't sync the highlighted index with the value when multiple
     // eslint-disable-next-line react-hooks/exhaustive-deps
     multiple ? false : value,

With this change, the onHighlightChange callback will be invoked twice, first after the index changed, and than after the filteredOptions changed. In order to fix this, I believe we will need to store in a ref the last highlighted value as well, not just the index and call the onHighlightChange handler only if these two values changed.

@oliviertassinari @herisn23 what do you think about this?

@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Sep 14, 2020

@mnajdova The proposed fix also solves #22073, the issue is that onHighlightChange gets desynchronized with what actually displayed.

However, the change breaks 2 tests in the regressions suite:

  • the first test is for #21280, we can observe the regression on /components/autocomplete/#githubs-picker. The scroll resets to the top when a value is unselected.
  • the second for #18611.

should keep focus on selected option and not reset to top option when options updated

we can observe the change of behavior with:

import React from "react";
import TextField from "@material-ui/core/TextField";
import Autocomplete from "@material-ui/lab/Autocomplete";

export default function BrokenAutocomplete() {
  const [options, setOptions] = React.useState(['one', 'two']);

  React.useEffect(() => {
    setTimeout(() => {
      setOptions(['one', 'two', 'three']);
    }, 4000);
  }, []);

  return (
    <Autocomplete
    options={options}
    renderInput={(params) => <TextField {...params} autoFocus />}
  />
  );
}

Arrow Down x2 before the timeout triggers. But we can probably break this test.


#21090 was the first step toward relying more on the input of options in the effect dependency. We need to

  • a. Keep the highlightedIndexRef state synchronized. The proposed diff helps with this matter. We can add regressions tests.
  • b. Find a way around the jumps of the scroll position that breaks the UX. I think that we need to expose a public API for this. The examples seem to suggest that, depending on the use cases, we can't have a universal logic. We always want to have the scroll in sync to the exception when the user is interacting with the component. We shouldn't scroll if
    a value is selected by the user with disableCloseOnSelect.
@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Sep 14, 2020

For b. maybe we should have a shouldScrollHighlightedIndex() callback that the developers can configure. By default, it's always true unless disableCloseOnSelect. Developers can have it return false. For instance in /components/autocomplete/#githubs-picker would always return false. Does it need to be an imperative callback? Maybe not, a prop could work too.

@mnajdova
Copy link
Member

@mnajdova mnajdova commented Sep 14, 2020

shouldScrollHighlightedIndex() sounds like a good solution in my opinion. We would have the best default we can, but developers can opt out of it 馃憤

@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Sep 15, 2020

@mnajdova Cool, I have added the good to take label. It's a quite a challenging one to get right, but at least we now have a clear direction a understanding of the problem.

@TakumaKira
Copy link

@TakumaKira TakumaKira commented Oct 14, 2020

Hi there,

Sorry if this might mess around the discussion so far, but I'd like to suggest a simpler solution for this.

I believe we can fix this issue by replacing filteredOptions.length === 0 to filteredOptions.toString() and this solution won't cause any other issues to fix with raised here.

Because checking filteredOptions in the second argument array of const syncHighlightedIndex = React.useCallback(() => {}, []) causes the extra rendering even if the result of calculated filteredOptions looks exactly the same. By converting it to the entire string, we can detect its change only when the result of filteredOptions is truly changed.

What do you think of this solution, @oliviertassinari @mnajdova?

@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Oct 14, 2020

@TakumaKira filteredOptions.toString() is equivalent to using filteredOptions.length. Happy to move forward with your proposal. The following diff does the trick. What we would need is polish the tests: update the description of the edited one, add a test case to cover the use case of this issue. Do you want to work on it? :)

diff --git a/packages/material-ui/src/Autocomplete/Autocomplete.test.js b/packages/material-ui/src/Autocomplete/Autocomplete.test.js
index 9a72f608e4..d9027bf14d 100644
--- a/packages/material-ui/src/Autocomplete/Autocomplete.test.js
+++ b/packages/material-ui/src/Autocomplete/Autocomplete.test.js
@@ -1269,7 +1269,7 @@ describe('<Autocomplete />', () => {

       // three option is added and autocomplete re-renders, two should still be highlighted
       setProps({ options: ['one', 'two', 'three'] });
-      checkHighlightIs(listbox, 'two');
+      checkHighlightIs(listbox, null);
     });

     it('should not select undefined', () => {
diff --git a/packages/material-ui/src/useAutocomplete/useAutocomplete.js b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
index 3fa4975012..d6ffc973d6 100644
--- a/packages/material-ui/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
@@ -468,7 +468,7 @@ export default function useAutocomplete(props) {
   }, [
     // Only sync the highlighted index when the option switch between empty and not
     // eslint-disable-next-line react-hooks/exhaustive-deps
-    filteredOptions.length === 0,
+    filteredOptions.length,
     // Don't sync the highlighted index with the value when multiple
     // eslint-disable-next-line react-hooks/exhaustive-deps
     multiple ? false : value,
@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Oct 14, 2020

Also, we can keep #22170 (comment) in the back of our mind in case somebody come with a use case in the future.

@TakumaKira
Copy link

@TakumaKira TakumaKira commented Oct 15, 2020

@oliviertassinari

Do you want to work on it? :)

I'd love to! But I realized things around here are getting complicated, and my change will soon affect other fixes. So I want to choose what I am going to do or not carefully here.

filteredOptions.toString() is equivalent to using filteredOptions.length.

filteredOptions.length won't fix this issue because the result of filteredOptions could be different even when the number of it stays the same. I think we should execute syncHighlightedIndex when filteredOptions are changed in any way and then should decide what we do inside syncHighlightedIndex. So I am going to stick with my original solution.

-      checkHighlightIs(listbox, 'two');
+      checkHighlightIs(listbox, null);

I had missed that this test will fail with my proposal. But we don't need this change with the fix below.

diff --git a/packages/material-ui/src/useAutocomplete/useAutocomplete.js b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
index 52d559872..82f8c457d 100644
--- a/packages/material-ui/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
@@ -422,7 +422,7 @@ export default function useAutocomplete(props) {
     const valueItem = multiple ? value[0] : value;
 
     // The popup is empty, reset
-    if (filteredOptions.length === 0 || valueItem == null) {
+    if (filteredOptions.length === 0) {
       changeHighlightedIndex({ diff: 'reset' });
       return;
     }

I believe resetting highlight when valueItem == null does not make sense, and the logic below is working(maybe as expected) by removing it.

I also found multiple is always false in syncHighlightedIndex so I can simplify around it, but before going ahead, I think we need to consider how we should do with highlight when multiple. Do you think there is any case we should touch highlightedIndex when multiple?

@oliviertassinari
Copy link
Member

@oliviertassinari oliviertassinari commented Oct 15, 2020

the result of filteredOptions could be different even when the number of it stays the same

@TakumaKira Definitely, that's an approximation taken in #22170 (comment) that is good enough for the provided reproduction of this issue (it solves it).

I think that filteredOptions.toString() is equivalent to filteredOptions.length because most real life use cases are array of objects:

[{}, {}].toString() === '[object Object],[object Object]'

I believe resetting highlight when valueItem == null does not make sense, and the logic below is working(maybe as expected) by removing it.

Did you look into the broken test cases when you do such?

I also found multiple is always false in syncHighlightedIndex

What do you mean? This prop is coming from the developers.

so I can simplify around it, but before going ahead, I think we need to consider how we should do with highlight when multiple

We have a load of test cases. I think that they will tell you.

@TakumaKira
Copy link

@TakumaKira TakumaKira commented Oct 19, 2020

@oliviertassinari

What do you mean? This prop is coming from the developers.

Sorry, I thought always multiple === false inside syncHighlightedIndex because of multiple ? false : value check(I found it was false).

We have a load of test cases. I think that they will tell you.

I should have run the tests before suggesting removing valueItem == null.
Below passed the tests.

diff --git a/packages/material-ui/src/Autocomplete/Autocomplete.test.js b/packages/material-ui/src/Autocomplete/Autocomplete.test.js
index f3e610268..8020f3e1c 100644
--- a/packages/material-ui/src/Autocomplete/Autocomplete.test.js
+++ b/packages/material-ui/src/Autocomplete/Autocomplete.test.js
@@ -2016,17 +2016,17 @@ describe('<Autocomplete />', () => {
       );
       const textbox = screen.getByRole('textbox');
 
+      fireEvent.keyDown(textbox, { key: 'ArrowDown' });
+      expect(handleHighlightChange.callCount).to.equal(2);
+      expect(handleHighlightChange.args[1][0]).to.not.equal(undefined);
+      expect(handleHighlightChange.args[1][1]).to.equal(options[0]);
+      expect(handleHighlightChange.args[1][2]).to.equal('keyboard');
+
       fireEvent.keyDown(textbox, { key: 'ArrowDown' });
       expect(handleHighlightChange.callCount).to.equal(3);
       expect(handleHighlightChange.args[2][0]).to.not.equal(undefined);
-      expect(handleHighlightChange.args[2][1]).to.equal(options[0]);
+      expect(handleHighlightChange.args[2][1]).to.equal(options[1]);
       expect(handleHighlightChange.args[2][2]).to.equal('keyboard');
-
-      fireEvent.keyDown(textbox, { key: 'ArrowDown' });
-      expect(handleHighlightChange.callCount).to.equal(4);
-      expect(handleHighlightChange.args[3][0]).to.not.equal(undefined);
-      expect(handleHighlightChange.args[3][1]).to.equal(options[1]);
-      expect(handleHighlightChange.args[3][2]).to.equal('keyboard');
     });
 
     it('should support mouse event', () => {
@@ -2042,10 +2042,10 @@ describe('<Autocomplete />', () => {
       );
       const firstOption = getAllByRole('option')[0];
       fireEvent.mouseOver(firstOption);
-      expect(handleHighlightChange.callCount).to.equal(3);
-      expect(handleHighlightChange.args[2][0]).to.not.equal(undefined);
-      expect(handleHighlightChange.args[2][1]).to.equal(options[0]);
-      expect(handleHighlightChange.args[2][2]).to.equal('mouse');
+      expect(handleHighlightChange.callCount).to.equal(2);
+      expect(handleHighlightChange.args[1][0]).to.not.equal(undefined);
+      expect(handleHighlightChange.args[1][1]).to.equal(options[0]);
+      expect(handleHighlightChange.args[1][2]).to.equal('mouse');
     });
 
     it('should pass to onHighlightChange the correct value after filtering', () => {
diff --git a/packages/material-ui/src/useAutocomplete/useAutocomplete.js b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
index 52d559872..4ccb17e97 100644
--- a/packages/material-ui/src/useAutocomplete/useAutocomplete.js
+++ b/packages/material-ui/src/useAutocomplete/useAutocomplete.js
@@ -422,7 +422,7 @@ export default function useAutocomplete(props) {
     const valueItem = multiple ? value[0] : value;
 
     // The popup is empty, reset
-    if (filteredOptions.length === 0 || valueItem == null) {
+    if (filteredOptions.length === 0) {
       changeHighlightedIndex({ diff: 'reset' });
       return;
     }
@@ -455,6 +455,11 @@ export default function useAutocomplete(props) {
       return;
     }
 
+    if (highlightedIndexRef.current === -1) {
+      changeHighlightedIndex({ diff: 'reset' });
+      return;
+    }
+
     // Prevent the highlighted index to leak outside the boundaries.
     if (highlightedIndexRef.current >= filteredOptions.length - 1) {
       setHighlightedIndex({ index: filteredOptions.length - 1 });
@@ -467,8 +472,7 @@ export default function useAutocomplete(props) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [
     // Only sync the highlighted index when the option switch between empty and not
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    filteredOptions.length === 0,
+    filteredOptions,
     // Don't sync the highlighted index with the value when multiple
     // eslint-disable-next-line react-hooks/exhaustive-deps
     multiple ? false : value,

I'm suggesting removing valueItem == null because it ignores valueItem === null && highlightedIndexRef.current !== -1 cases and therefore bothering to keep highlightedIndex like tests you raised.

I think that filteredOptions.toString() is equivalent to filteredOptions.length because most real life use cases are array of objects:

Not checking the change of the option itself would miss changes if the length of the result stays the same. So if filteredOptions could be array of objects, we'd better observe filteredOptions and the last highlighted value as @mnajdova suggested. But with the fix above, the issue will be solved a lot simpler, I think.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can鈥檛 perform that action at this time.