Repository: mui/material-ui (TypeScript)
Date: 2020-03-22 11:40:08+00:00
Task type: Bug Fix

Issue:
[TreeItem] TreeItem hijacks focus when typing on an input box that updates it.
I am using a <TextField /> to change the contents of a TreeView and a matching TreeItem is being focused when I backspace.

CodeSandbox: https://codesandbox.io/s/bold-hill-rw9y5

- [X] The issue is present in the latest release.
- [X] I have searched the [issues](https://github.com/mui-org/material-ui/issues) of this repository and believe that this is not a duplicate.

## Current Behavior 😯

The focus is lost from the text field and hijacked by the TreeItem

## Expected Behavior 🤔

The focus should remain on the TextField since the user never selected or focused on the TreeItem.

1. Go to the sandbox: https://codesandbox.io/s/bold-hill-rw9y5
2. Collapse and expand a category.
3. Click the TextField search box and type "bananas"
4. Start deleting and you'll notice you loose focus mid way before you clear the query.

## Context 🔦
I want to be able to filter out items from a tree view.



Issue discussion:
Can't reproduce:
![mui-tree](https://user-images.githubusercontent.com/12292047/75520751-94bded00-5a06-11ea-8c01-ce32e1c4b122.gif)


Please include your environment as was requested in the template.

```md
## Your Environment 🌎

<!--
  Include as many relevant details about the environment with which you experienced the bug.
  If you encounter issues with typescript please include version and tsconfig.
-->

| Tech        | Version |
| ----------- | ------- |
| Material-UI | v4.?.?  |
| React       |         |
| Browser     |         |
| TypeScript  |         |
| etc.        |         |

```
```
    "@material-ui/core": "4.9.4",
    "@material-ui/icons": "4.9.1",
    "@material-ui/lab": "4.0.0-alpha.44",
    "react": "16.12.0",
    "react-dom": "16.12.0",
```

![ezgif-7-84f769697dd8](https://user-images.githubusercontent.com/4448627/75521104-9965af80-59cc-11ea-8748-2604d9484344.gif)

> ```
>     "@material-ui/core": "4.9.4",
>     "@material-ui/icons": "4.9.1",
>     "@material-ui/lab": "4.0.0-alpha.44",
>     "react": "16.12.0",
>     "react-dom": "16.12.0",
> ```
> 
> ![ezgif-7-84f769697dd8](https://user-images.githubusercontent.com/4448627/75521104-9965af80-59cc-11ea-8748-2604d9484344.gif)

That is not reproducing what you describe though:

> Start deleting and you'll notice you loose focus mid way before you clear the query.

Could you describe the additional steps from your gif that are missing from the initial description?

Also please include your browser.

@eps1lon  My apologies. Honestly it's not exactly the same each time. What I do to reproduce is to open and close the nodes multiple times, and then start typing items in the list and deleting the query again. Repeating the whole process until at some random point it freezes and I loose focus.

Running
Chrome Version 79.0.3945.130 (Official Build) (64-bit) on Mac OS 10.15.3 (19D76)
I think I got it including the crash:

![mui-tree](https://user-images.githubusercontent.com/12292047/75533855-0309ac00-5a15-11ea-8f59-e55151125f8d.gif)

The crash happens when I press `a` after the tree stole focus.

[Confirmed on `master`](https://codesandbox.io/s/condescending-waterfall-mlohl)
Thank you @eps1lon. Happy to work this out if I can be given any pointers.

Frustration made me just think of a "disableKeyEvents" setting on my project to get around it but we probably need something better.

Let me know and happy to help.
With a confirmed reproduction you can work on this. Would be greatly appreciated!
@eps1lon I think I need a pointer on why this could happen if I want to fix this with a better solution other than "disableKeyEvents".

If you're okay with that quick fix then happy to send a PR just now.
I think starting with a test in https://github.com/mui-org/material-ui/blob/master/packages/material-ui-lab/src/TreeView/TreeView.test.js that replays the reproduction would be a good start. Then it might be clearer how the fix should look like.
Quick update: Fixed this on the weekend but was having trouble getting a test to pass.
> Quick update: Fixed this on the weekend but was having trouble getting a test to pass.

Do you have the branch pushed so that I can take a look?
@eps1lon I’ll push it when I get home 
@eps1lon Sorry it took so long!
@joshwooding @eps1lon 
There are two issues here.
Firstly there is the focus stealing and secondly the [crash](https://github.com/mui-org/material-ui/issues/19882#issuecomment-592427810).

The crash occurs because the TreeItem is not cleaned up properly by the TreeView.

When the TreeItem is removed it informs the TreeView.
```
React.useEffect(() => {
    if (removeNodeFromNodeMap) {
      return () => {
        removeNodeFromNodeMap(nodeId);
      };
    }
    return undefined;
  }, [nodeId, removeNodeFromNodeMap]);
```
The TreeView cleans up the nodeMap
```
const removeNodeFromNodeMap = React.useCallback(
    id => {
      const nodes = getNodesToRemove(id);
      const newMap = { ...nodeMap.current };

      nodes.forEach(node => {
        const map = newMap[node];
        if (map) {
          if (map.parent) {
            const parentMap = newMap[map.parent];
            if (parentMap && parentMap.children) {
              const parentChildren = parentMap.children.filter(c => c !== node);
              newMap[map.parent] = { ...parentMap, children: parentChildren };
            }
          }

          delete newMap[node];
        }
      });
      nodeMap.current = newMap;
    },
    [getNodesToRemove],
  );
```

But when we then focus by first character - as did @eps1lon - the nodeMap has been cleaned up but the firstCharMap has not and the **map variable is undefined**
```
const focusByFirstCharacter = (id, char) => {
    let start;
    let index;
    const lowercaseChar = char.toLowerCase();

    const firstCharIds = [];
    const firstChars = [];
    
    Object.keys(firstCharMap.current).forEach(nodeId => {
      const firstChar = firstCharMap.current[nodeId];
     
      const map = nodeMap.current[nodeId]; // *****************************************
      const visible = map.parent ? isExpanded(map.parent) : true;
```
Could we just add a null check before `map.parent` to prevent the crash?

```      
const visible = map && map.parent ? isExpanded(map.parent) : true;
```

...but there's also the issue of focus stealing.  I'm still trying to wrap my head around the details of the component, but I'd like to look at it more tonight when I have more time if no one's currently looking into it. 
@tonyhallett Thanks for the help. I've already fixed the crash in #19973 but having trouble with the asynchronous nature of the test.
@joshwooding Your test 'works after conditional rendering'  deals with ArrowDown.  

https://github.com/mui-org/material-ui/issues/19882#issuecomment-592427810

![image](https://user-images.githubusercontent.com/11292998/76471168-dc228100-63e9-11ea-925b-85d253e6d2c5.png)

Is related to focusByFirstCharacter.  

@mlizchap 

```
it('should not throw when an item has been removed', () => {
      function TestComponent() {
        const [hide, setState] = React.useState(false);

        return (
          <React.Fragment>
            <button type="button" onClick={() => setState(true)}>
              Hide
            </button>
            <TreeView data-testid='treeView'>
              {!hide && <TreeItem nodeId="test" label="test" />}
              <TreeItem nodeId='focusByFirstChar' data-testid='focusByFirstChar' label='focusByFirstChar'/>

            </TreeView>
          </React.Fragment>
        );
      }
      const { getByText, getByRole } = render(<TestComponent />);
      fireEvent.click(getByText('Hide'));
      expect(()=>{
        fireEvent.keyDown(getByRole('treeitem'), { key: 'a'});
      }).not.to.throw()
    })

```
and the resolution

```
const removeNodeFromNodeMap = React.useCallback(
    id => {
      const nodes = getNodesToRemove(id);
      const newMap = { ...nodeMap.current };
      const newFirstCharMap = { ...firstCharMap.current}; //*******
      nodes.forEach(node => {
        const map = newMap[node];
        if(newFirstCharMap[node]){ //******
          delete newFirstCharMap[node];//******
        }
        if (map) {
          if (map.parent) {
            const parentMap = newMap[map.parent];
            if (parentMap && parentMap.children) {
              const parentChildren = parentMap.children.filter(c => c !== node);
              newMap[map.parent] = { ...parentMap, children: parentChildren };
            }
          }

          delete newMap[node];
        }
      });
      nodeMap.current = newMap;
      firstCharMap.current = newFirstCharMap; // *********
    },
    [getNodesToRemove],
  );

```
I was about to make a pull request for this.  Proceed ?  If desired could extract the firstCharMap clean up to a separate function.
@tonyhallet Sounds good. It's probably better to make a separate function for the firstCharMap clean up part.
@joshwooding Will do.  Be tomorrow though
@tonyhallett Cool. I would also add a null check for the map.parent in focusByFirstCharacter too.
The sandbox provided https://codesandbox.io/s/bold-hill-rw9y5 illustrates two crashes.  The first due to focusByFirstCharacter is still an issue. The second can be thrown with arrow down - this does not occur with the latest build.

Solution patch:
diff --git a/packages/material-ui-lab/src/TreeView/TreeView.js b/packages/material-ui-lab/src/TreeView/TreeView.js
--- a/packages/material-ui-lab/src/TreeView/TreeView.js
+++ b/packages/material-ui-lab/src/TreeView/TreeView.js
@@ -56,9 +56,10 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
     ...other
   } = props;
   const [tabbable, setTabbable] = React.useState(null);
-  const [focused, setFocused] = React.useState(null);
+  const [focusedNodeId, setFocusedNodeId] = React.useState(null);
 
   const nodeMap = React.useRef({});
+
   const firstCharMap = React.useRef({});
   const visibleNodes = React.useRef([]);
 
@@ -88,7 +89,7 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
   );
 
   const isTabbable = (id) => tabbable === id;
-  const isFocused = (id) => focused === id;
+  const isFocused = (id) => focusedNodeId === id;
 
   /*
    * Node Helpers
@@ -129,7 +130,7 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
   const focus = (id) => {
     if (id) {
       setTabbable(id);
-      setFocused(id);
+      setFocusedNodeId(id);
     }
   };
 
@@ -181,7 +182,7 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
    * Expansion Helpers
    */
 
-  const toggleExpansion = (event, value = focused) => {
+  const toggleExpansion = (event, value = focusedNodeId) => {
     let newExpanded;
     if (expanded.indexOf(value) !== -1) {
       newExpanded = expanded.filter((id) => id !== value);
@@ -431,6 +432,13 @@ const TreeView = React.forwardRef(function TreeView(props, ref) {
         }
       });
       nodeMap.current = newMap;
+
+      setFocusedNodeId((oldFocusedNodeId) => {
+        if (oldFocusedNodeId === id) {
+          return null;
+        }
+        return oldFocusedNodeId;
+      });
     },
     [getNodesToRemove],
   );


Test patch:
diff --git a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
--- a/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
+++ b/packages/material-ui-lab/src/TreeItem/TreeItem.test.js
@@ -3,7 +3,7 @@ import { expect } from 'chai';
 import { spy } from 'sinon';
 import { createMount, getClasses } from '@material-ui/core/test-utils';
 import describeConformance from '@material-ui/core/test-utils/describeConformance';
-import { createEvent, createClientRender, fireEvent } from 'test/utils/createClientRender';
+import { act, createEvent, createClientRender, fireEvent } from 'test/utils/createClientRender';
 import TreeItem from './TreeItem';
 import TreeView from '../TreeView';
 
@@ -1005,4 +1005,44 @@ describe('<TreeItem />', () => {
     fireEvent(input, keydownEvent);
     expect(keydownEvent.preventDefault.callCount).to.equal(0);
   });
+
+  it('should not focus steal', () => {
+    let setActiveItemMounted;
+    // a TreeItem whose mounted state we can control with `setActiveItemMounted`
+    function ControlledTreeItem(props) {
+      const [mounted, setMounted] = React.useState(true);
+      setActiveItemMounted = setMounted;
+
+      if (!mounted) {
+        return null;
+      }
+      return <TreeItem {...props} />;
+    }
+    const { getByText, getByTestId, getByRole } = render(
+      <React.Fragment>
+        <button type="button">Some focusable element</button>
+        <TreeView>
+          <TreeItem nodeId="one" label="one" data-testid="one" />
+          <ControlledTreeItem nodeId="two" label="two" data-testid="two" />
+        </TreeView>
+      </React.Fragment>,
+    );
+
+    fireEvent.click(getByText('two'));
+
+    expect(getByTestId('two')).to.have.focus;
+
+    getByRole('button').focus();
+
+    expect(getByRole('button')).to.have.focus;
+
+    act(() => {
+      setActiveItemMounted(false);
+    });
+    act(() => {
+      setActiveItemMounted(true);
+    });
+
+    expect(getByRole('button')).to.have.focus;
+  });
 });