Repository: downshift-js/downshift (JavaScript)

Issue:
Warning in test when typing with useSelect

- `downshift` version: 1.1.0
- `node` version: v12.16.2
- `npm` (or `yarn`) version: 1.22.4


**What you did**:
I created a Select component with `useSelect` and wrote a test to enter press the keyboard and select an element:

``` javascript
const listbox = screen.getByRole("listbox");
userEvent.type(listbox, "10{enter}");
```

**What happened**:

The test works as expected, but I get a warning in the console:

```
 console.error
    Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
```

**Problem description**:
`useSelect` stores the user key inputs and uses a debounced function to clear those inputs after 500ms. But, the debounced function is not cancelled when the component unmounts. The result is the clearing function is called after the component unmounts and prints a warning in the console.

See https://github.com/downshift-js/downshift/blob/master/src/hooks/useSelect/index.js#L112

This issue is not encountered in downshift's useSelect tests because timers are mocked and the debounced call is cleared here: https://github.com/downshift-js/downshift/blob/master/src/hooks/useSelect/__tests__/getMenuProps.test.js#L312.

**Suggested solution**:
1. Break the creation of the debounced callback and its invocation into separate effects, and
2. Cancel the debounced function on unmount:

```javascript
// Create and clear the debounced function in a single effect
useEffect(function () {
  // init the clean function here as we need access to dispatch.
  if (isInitialMountRef.current) {
    clearTimeoutRef.current = debounce(function (outerDispatch) {
      outerDispatch({
        type: FunctionSetInputValue,
        inputValue: ''
      });
    }, 500);
  }

  // Cancel the debounced function
  return () => {
      clearTimeoutRef.current.cancel()
  }
}, [dispatch])

// And invoke it in a separate effect
useEffect(() => {
  if (!inputValue) {
    return;
  }

  clearTimeoutRef.current(dispatch);
}, [ inputValue]);
```


Discussion:
Hi! Thanks for creating the issue. I think it makes sense, I will look in more detail later. In the meantime can you create a PR with these changes? And also add a unit test for this, as it should probably be possible with react hooks testing library.

I will be happy to review the changes and merge them as a fix. Thank you!
I guess I'm encountering this also today, with the following pattern of pushing a new router state in `onSelectedItemChange` (using `@reach/router`'s `navigate` function):

```js
useCombobox({
  // ...
  onSelectedItemChange: (changes) => {
    if (changes.selectedItem) {
      navigate(changes.selectedItem.path);
    }
  },
  // ...
```

Is there a recommended way of working around this in the meantime?

---

Error:

```
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
```
**Workaround:**

I guess one way of working around it for now is to wrap the `navigate` call in a `setTimeout`, seems to avoid the issue:

```diff
useCombobox({
  // ...
  onSelectedItemChange: (changes) => {
    if (changes.selectedItem) {
-     navigate(changes.selectedItem.path);
+     setTimeout(() => navigate(changes.selectedItem.path));
    }
  },
  // ...
```
If you can put a PR that fixes this issue by cleaning the timer up and also test it, I will merge and you can get the updated useSelect without that workaround.
Sorry, I got distracted and forgot about this. I can put together an MR.

Any suggestions on how to test this?
Testing library has an unmount function. You can trigger a key change with the menu open while the timers are still running, unmount, test that the console error is not called, run the timers (cleanup) and done. This test should fail before you fix it, so I would actually start by writing the test, check that it's failing (console error should be called with the error) and then fix it.
I can help you with writing the test if you get stuck.
Sounds good, thanks for the tip! I'll give that a shot.
Okay, so I've written a test that correctly fails in the current implementation and passes with my adjustment.

But, it is missing some coverage and I'm having trouble figuring out where (other than the specific file that I've updated). I've tried updating the jest.config.js to use the lcov reporter so I can see which line is omitted in the browser, but can't seem to get that to work. Any tips on how to get a more helpful coverage report here?

See MR #1256 for my changes, including the test
Does this also need to be applied to `useCombobox`, as in [my comment above](https://github.com/downshift-js/downshift/issues/1226#issuecomment-808948684)?

Or is my error coming from something else?
@karlhorky , Unfortunately, I think your issue is unrelated. It doesn't look like `useCombobox` uses `debounce`, as `useSelect` does.
@silviuaavram I was able to fix the test.

The coverage gap was caused by the `if` condition always being true now that I've separated the effect into two. We no longer need to check that the component is mounted because the effect is now has an empty dependency array and will only be called once on mount.
Love the changes! Great job!
@silviuaavram thanks for the review and the merge!

Could you provide me with some guidance on what to do about this same `Can't perform a React state update on an unmounted component ` warning appearing when using `useCombobox`, as in [my comment above](https://github.com/downshift-js/downshift/issues/1226#issuecomment-808948684)? Should I open another issue for this?
Yes, please open a similar one for useCombobox. It should be different from
useSelect as the cause is unrelated. Thank you!

On Wed 31. 3. 2021 at 1:00, Karl Horky ***@***.***> wrote:

> @silviuaavram <https://github.com/silviuaavram> thanks for the review and
> the merge!
>
> Could you provide me with some guidance on what to do about this same Can't
> perform a React state update on an unmounted component warning appearing
> when using useCombobox, as in my comment above
> <https://github.com/downshift-js/downshift/issues/1226#issuecomment-808948684>?
> Should I open another issue for this?
>
> —
> You are receiving this because you were mentioned.
>
>
> Reply to this email directly, view it on GitHub
> <https://github.com/downshift-js/downshift/issues/1226#issuecomment-810606073>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/ACWAZABEGYT2CYXUMJSZ5MLTGJCX5ANCNFSM4WYCINYA>
> .
>

Ok, opened over here, thanks! https://github.com/downshift-js/downshift/issues/1259



Patch:
diff --git a/src/hooks/useSelect/index.js b/src/hooks/useSelect/index.js
index e0e6a161..7aa2b1ca 100644
--- a/src/hooks/useSelect/index.js
+++ b/src/hooks/useSelect/index.js
@@ -108,23 +108,32 @@ function useSelect(userProps = {}) {
     scrollIntoView,
     getItemNodeFromIndex,
   })
-  // Sets cleanup for the keysSoFar after 500ms.
+
+  // Sets cleanup for the keysSoFar callback, debounded after 500ms.
   useEffect(() => {
     // init the clean function here as we need access to dispatch.
-    if (isInitialMountRef.current) {
-      clearTimeoutRef.current = debounce(outerDispatch => {
-        outerDispatch({
-          type: stateChangeTypes.FunctionSetInputValue,
-          inputValue: '',
-        })
-      }, 500)
+    clearTimeoutRef.current = debounce(outerDispatch => {
+      outerDispatch({
+        type: stateChangeTypes.FunctionSetInputValue,
+        inputValue: '',
+      })
+    }, 500)
+
+    // Cancel any pending debounced calls on mount
+    return () => {
+      clearTimeoutRef.current.cancel()
     }
+  }, [])
 
+  // Invokes the keysSoFar callback set up above.
+  useEffect(() => {
     if (!inputValue) {
       return
     }
+
     clearTimeoutRef.current(dispatch)
   }, [dispatch, inputValue])
+
   useControlPropsValidator({
     isInitialMount: isInitialMountRef.current,
     props,


Test patch:
diff --git a/src/hooks/useSelect/__tests__/getMenuProps.test.js b/src/hooks/useSelect/__tests__/getMenuProps.test.js
index 1e74f71e..c07a5e10 100644
--- a/src/hooks/useSelect/__tests__/getMenuProps.test.js
+++ b/src/hooks/useSelect/__tests__/getMenuProps.test.js
@@ -397,6 +397,22 @@ describe('getMenuProps', () => {
           // highlight should stay on the first item starting with 'C'
           expect(menu).toHaveAttribute('aria-activedescendant', expectedIndex)
         })
+
+        test('should not attempt update after unmount', () => {
+          const consoleErrorSpy = jest.spyOn(console, 'error')
+          const char = 'c'
+          const {keyDownOnMenu, unmount} = renderSelect({
+            isOpen: true,
+          })
+
+          // Enter key twice to queue up debounced call
+          keyDownOnMenu(char)
+          keyDownOnMenu(char)
+          unmount()
+          jest.runAllTimers()
+
+          expect(consoleErrorSpy).not.toHaveBeenCalled()
+        })
       })
 
       describe('arrow up', () => {