Repository: mui/material-ui (TypeScript)
Date: 2020-11-02 10:51:56+00:00
Task type: Bug Fix

Issue:
[ClickAwayListener] Is being fired without an onClick event after upgrading react and react-dom to v17
On my app I have a menu that worked fine until I upgraded `react` and `react-dom`.
After upgrading `react` and `react-dom` from  version `16.14.0` to version `17.0.0`- on menu click, the menu flashes-it opens and closes right away.
<!--
  Thank you very much for contributing to Material-UI by creating an issue! ❤️
  To avoid duplicate issues we ask you to check off the following list.
-->

<!-- Checked checkbox should look like this: [x] -->

- [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 😯

On menu click- the menu opens and closes right away, for some reason the `ClickAwayListener` is being fired.

## Expected Behavior 🤔

On menu click- the menu should open, on click away it should close.

## Steps to Reproduce 🕹

<!--
  Provide a link to a live example (you can use codesandbox.io) and an unambiguous set of steps to reproduce this bug.
  Include code to reproduce, if relevant (which it most likely is).

  You should use the official codesandbox template as a starting point: https://material-ui.com/r/issue-template

  If you have an issue concerning TypeScript please start from this TypeScript playground: https://material-ui.com/r/ts-issue-template

  Issues without some form of live example have a longer response time.
-->

Steps:

1. Upgrade `react` and `react-dom` to v`17.0.0`
2. Click the menu

Codesandbox:
~https://codesandbox.io/s/material-ui-issue-forked-byfgm?file=/src/index.js~ Invalid. New repro: https://codesandbox.io/s/material-ui-issue-forked-13053?file=/src/Demo.js

## 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/core | v4.11.0 |
| React       | v17.0.0        |
| React-dom       | v17.0.0        |
| Chrome | v86.0.4240.75 |



Issue discussion:
Oh wow, here's what's going on:

1. Click on the icon button, a DOM event (a.) is triggered.
1. The event bubbles up to the React host.
1. React bubbles the event on each React component.
1. React calls the `onClick` handler on the icon button.
1. React renders the `ClickAwayListener`.
1. `ClickAwayListener` sets a listener on the document.
1. The drawer opens.
1. The same event (a.) keeps bubbling. It reaches the new listener set by the `ClickAwayListener`.
1. The drawer closes.

In React 16 the host in 2. is the document, in React 17 the host is an element in the body, hence the difference.
~Do we really need to fix this? What's the use case for the ClickAwayListener here? The current workaround is to call the setState in a setTimeout().~

This change internally produces the expected behavior of the codesandbox. It's unclear if we want to move forward with it.

```diff
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
index a25f43baca..e31c371924 100644
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -49,6 +49,7 @@ function ClickAwayListener(props) {
     // Given developers can stop the propagation of the synthetic event,
     // we can only be confident with a positive value.
     const insideReactTree = syntheticEventRef.current;
@@ -130,9 +131,12 @@ function ClickAwayListener(props) {
       const mappedMouseEvent = mapEventPropToEvent(mouseEvent);
       const doc = ownerDocument(nodeRef.current);

-      doc.addEventListener(mappedMouseEvent, handleClickAway);
+      // Use a setTimeout to avoid capturing the click that has tricker the mount of the ClickAwayListener component.
+      const timeout = setTimeout(() => {
+        doc.addEventListener(mappedMouseEvent, handleClickAway);
+      });

       return () => {
+        clearTimeout(timeout);
         doc.removeEventListener(mappedMouseEvent, handleClickAway);
       };
     }
```

@eps1lon What do you think?

It's also probably time to do? :)

```diff
diff --git a/packages/material-ui/package.json b/packages/material-ui/package.json
index be6c895d41..c0ab3251f4 100644
--- a/packages/material-ui/package.json
+++ b/packages/material-ui/package.json
@@ -44,7 +44,7 @@
     "@emotion/core": "^10.0.27",
     "@emotion/styled": "^10.0.27",
     "@types/react": "^16.8.6",
-    "react": "^16.8.0",
+    "react": "^16.8.0 || ^17.0.0",
     "react-dom": "^16.8.0"
   },
   "peerDependenciesMeta": {
```
This is a problem in our UI library built on top of Material as well. Curiously, changing the `mouseEvent` prop on `ClickAwayListener` from `onClick` → `onMouseDown` fixes the issue. I'm not aware enough of low-level JS events to determine whether this is a safe one-for-one swap or not.

We'd love to see it fixed though 🙏 
> `onClick` → `onMouseDown`

@dargue3 The only downside I'm aware of is that click away will fire false-positive when moving the scrollbar on nested containers (not the body).
> What's the use case for the ClickAwayListener here?

The use case is being able to close the slide-out menu while clicking anywhere besides for the menu
> The use case is being able to close the slide-out menu while clicking anywhere besides for the menu

That's the use case :point_up_2: 
I'm closing the use case isn't valid, use the `onClose` callback instead.
Thank you for your immediate assistance,
as you suggested, switching to the `onClose` callback works fine:

`<Drawer open={open} onClick={() => setTimeout(onClose, 1)}>`

Thank you!

What about the use case where you have a `<Popper />` and put a `<ClickAwayListener />` on the `children`?
@stevewillard Do you have a reproduction?
[Sandbox ](https://codesandbox.io/s/material-ui-issue-forked-13053?file=/src/Demo.js) for the issue @stevewillard stated. 
Using a popper with a clickaway listener in it. 

@wkerswell-gresham Thanks. I would propose we move forward with https://github.com/mui-org/material-ui/issues/23215#issuecomment-716042325. Do we have another alternative?
Is it safe to stop propagation instead?
@artisonian Do you have a working diff to propose? Stopping propagation is generally considered a bad practice. If the event should absolutely not be consumed by a parent, then yes, but we have to be certain.
I spent some time in the source code...didn't realize the event listeners are being attached inside of an effect block. Never mind `stopPropagation` then.
I am seeing this issue as well with a `<Popper />` containing a `<ClickAwayListener />` since upgrading to React 17. Removing the `ClickAwayListener` resolves the issue but that doesn't solve our problem as we need it to dismiss our popover menus when clicking outside the menu.
@wkerswell-gresham A workaround: https://codesandbox.io/s/material-ui-issue-forked-zb3i5?file=/src/Demo.js

```diff
  console.log("settingsMenuOpen", settingsMenuOpen);

  const handleClick = (event) => {
    const target = event.currentTarget;
+   setTimeout(() => {
      setAnchorEl(anchorEl ? null : target);
+   });
  };

```
Stopping propagation on `onClick` handlers that trigger the popper being opened is another workaround.
I'm just noticing we have the same issue with a `<Snackbar />`, 
`<ClickAwayListener />` is being fired and closes the  `<Snackbar />` right away.

[Sandbox ](https://codesandbox.io/s/material-ui-issue-forked-5jyox?file=/src/Demo.js)for the issue.
@mforman1 Thanks for the reproduction. Do you want to work on the proposed fix? We would probably only miss a test case to ensure we don't have regression in the future. It would also involve the `react-next.diff` file that we use to run all the tests with React 16 and 17.
I understand that everybody wants to switch right now but note that we don't officially support React 17 yet (hence the peer dependency mismatch warning you should see). We fixed most compat issue but some still slip through since we ourselves haven't fully switched yet. 

Material-UI + React 17 is something you should consider as unstable so do your end-users a favor and don't switch to it in production yet. React 17 hasn't any big features for end-users anyway.

When it's safer to switch to React 17 we'll definitely announce that in the changelog. But it's only been out for a few days so the ecosystem needs some time to adjust. Thanks for understanding!

Current investigation:
`useEffect` timing changed in React 17 when portaled. So we're affected by https://github.com/facebook/react/issues/20074 as well. It's interesting because I thought that event handlers should be attached synchronously. Though it's still weird to me how this used to work in class components where it was also attached synchronously. Though maybe it never did. Unfortunately the React is has been deprioritized so we have to come up with a fix in userland.
Tracking investigation in https://github.com/mui-org/material-ui/issues/23215#issuecomment-719046162.

Another workaround for now is 

```diff
<Popper
  id="menuButton"
  open={settingsMenuOpen}
  anchorEl={anchorEl}
+  disablePortal
>
```
if you don't rely on portaling.
Will this fix be going into v4, or will it be v5 only?
> Will this fix be going into v4, or will it be v5 only?

This fix will be backported to the next v4 feature release but that one will also include deprecation warnings. The recommendation in https://github.com/mui-org/material-ui/issues/23215#issuecomment-719046162 still stands: Material-UI v4 + React 17 is unstable and not fit for production yet.

Solution patch:
diff --git a/.eslintrc.js b/.eslintrc.js
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -169,6 +169,8 @@ module.exports = {
         'jsx-a11y/no-static-element-interactions': 'off',
         'jsx-a11y/tabindex-no-positive': 'off',
 
+        // In tests this is generally intended.
+        'react/button-has-type': 'off',
         // They are accessed to test custom validator implementation with PropTypes.checkPropTypes
         'react/forbid-foreign-prop-types': 'off',
         // components that are defined in test are isolated enough
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.js
@@ -31,13 +31,17 @@ function ClickAwayListener(props) {
   } = props;
   const movedRef = React.useRef(false);
   const nodeRef = React.useRef(null);
-  const mountedRef = React.useRef(false);
+  const activatedRef = React.useRef(false);
   const syntheticEventRef = React.useRef(false);
 
   React.useEffect(() => {
-    mountedRef.current = true;
+    // Ensure that this component is not "activated" synchronously.
+    // https://github.com/facebook/react/issues/20074
+    setTimeout(() => {
+      activatedRef.current = true;
+    }, 0);
     return () => {
-      mountedRef.current = false;
+      activatedRef.current = false;
     };
   }, []);
 
@@ -63,7 +67,7 @@ function ClickAwayListener(props) {
     // 1. IE 11 support, which trigger the handleClickAway even after the unbind
     // 2. The child might render null.
     // 3. Behave like a blur listener.
-    if (!mountedRef.current || !nodeRef.current || clickedRootScrollbar(event)) {
+    if (!activatedRef.current || !nodeRef.current || clickedRootScrollbar(event)) {
       return;
     }
 
diff --git a/scripts/react-next.diff b/scripts/react-next.diff
--- a/scripts/react-next.diff
+++ b/scripts/react-next.diff
@@ -79,10 +79,10 @@ index ed0e37f214..49d8ea9b0f 100644
          'Material-UI: You have provided an invalid combination of props to the Breadcrumbs.\nitemsAfterCollapse={2} + itemsBeforeCollapse={2} >= maxItems={3}',
        );
 diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
-index 5e538c8ea3..3b7d03c987 100644
+index fdf7e6e3ae..5d58e3fdeb 100644
 --- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
 +++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
-@@ -132,8 +132,7 @@ describe('<ClickAwayListener />', () => {
+@@ -160,8 +160,7 @@ describe('<ClickAwayListener />', () => {
        expect(handleClickAway.callCount).to.equal(0);
  
        fireEvent.click(getByText('Stop inside a portal'));
@@ -90,8 +90,8 @@ index 5e538c8ea3..3b7d03c987 100644
 -      expect(handleClickAway.callCount).to.equal(1);
 +      expect(handleClickAway.callCount).to.equal(0);
      });
-   });
  
+     it('should not be called during the same event that mounted the ClickAwayListener', () => {
 diff --git a/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js b/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js
 index 09daadd961..1eaf806289 100644
 --- a/packages/material-ui/src/TextareaAutosize/TextareaAutosize.test.js


Test patch:
diff --git a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
--- a/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
+++ b/packages/material-ui/src/ClickAwayListener/ClickAwayListener.test.js
@@ -1,12 +1,40 @@
 import * as React from 'react';
+import * as ReactDOM from 'react-dom';
 import { expect } from 'chai';
-import { spy } from 'sinon';
+import { spy, stub, useFakeTimers } from 'sinon';
 import { createClientRender, fireEvent, screen } from 'test/utils/createClientRender';
 import Portal from '../Portal';
 import ClickAwayListener from './ClickAwayListener';
 
 describe('<ClickAwayListener />', () => {
-  const render = createClientRender();
+  /**
+   * @type {ReturnType<typeof useFakeTimers>}
+   */
+  let clock;
+  beforeEach(() => {
+    clock = useFakeTimers();
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
+  const clientRender = createClientRender();
+  /**
+   * @type  {typeof plainRender extends (...args: infer T) => any ? T : enver} args
+   *
+   * @remarks
+   * This is for all intents and purposes the same as our client render method.
+   * `plainRender` is already wrapped in act().
+   * However, React has a bug that flushes effects in a portal synchronously.
+   * We have to defer the effect manually like `useEffect` would so we have to flush the effect manually instead of relying on `act()`.
+   * React bug: https://github.com/facebook/react/issues/20074
+   */
+  function render(...args) {
+    const result = clientRender(...args);
+    clock.next();
+    return result;
+  }
 
   it('should render the children', () => {
     const children = <span />;
@@ -135,6 +163,48 @@ describe('<ClickAwayListener />', () => {
       // True-negative, we don't have enough information to do otherwise.
       expect(handleClickAway.callCount).to.equal(1);
     });
+
+    it('should not be called during the same event that mounted the ClickAwayListener', () => {
+      function Test() {
+        const [open, setOpen] = React.useState(false);
+
+        return (
+          <React.Fragment>
+            <button data-testid="trigger" onClick={() => setOpen(true)} />
+            {open &&
+              ReactDOM.createPortal(
+                <ClickAwayListener onClickAway={() => setOpen(false)}>
+                  <div data-testid="child" />
+                </ClickAwayListener>,
+                // Needs to be an element between the react root we render into and the element where CAL attaches its native listener (now: `document`).
+                document.body,
+              )}
+          </React.Fragment>
+        );
+      }
+      render(<Test />);
+
+      const consoleSpy = stub(console, 'error');
+      try {
+        // can't wrap in `act()` since that changes update semantics.
+        // We want to simulate a discrete update.
+        // `act()` currently triggers a batched update: https://github.com/facebook/react/blob/3fbd47b86285b6b7bdeab66d29c85951a84d4525/packages/react-reconciler/src/ReactFiberWorkLoop.old.js#L1061-L1064
+        screen.getByTestId('trigger').click();
+
+        const missingActWarningsEnabled = typeof jest !== 'undefined';
+        if (missingActWarningsEnabled) {
+          expect(
+            consoleSpy.alwaysCalledWithMatch('not wrapped in act(...)'),
+            consoleSpy.args,
+          ).to.equal(true);
+        } else {
+          expect(consoleSpy.callCount).to.equal(0);
+        }
+        expect(screen.getByTestId('child')).not.to.equal(null);
+      } finally {
+        consoleSpy.restore();
+      }
+    });
   });
 
   describe('prop: mouseEvent', () => {
diff --git a/packages/material-ui/src/Snackbar/Snackbar.test.js b/packages/material-ui/src/Snackbar/Snackbar.test.js
--- a/packages/material-ui/src/Snackbar/Snackbar.test.js
+++ b/packages/material-ui/src/Snackbar/Snackbar.test.js
@@ -9,10 +9,38 @@ import describeConformance from '../test-utils/describeConformance';
 import Snackbar from './Snackbar';
 
 describe('<Snackbar />', () => {
+  /**
+   * @type {ReturnType<typeof useFakeTimers>}
+   */
+  let clock;
+  beforeEach(() => {
+    clock = useFakeTimers();
+  });
+
+  afterEach(() => {
+    clock.restore();
+  });
+
   // StrictModeViolation: uses Slide
   const mount = createMount({ strict: false });
   let classes;
-  const render = createClientRender({ strict: false });
+
+  const clientRender = createClientRender({ strict: false });
+  /**
+   * @type  {typeof plainRender extends (...args: infer T) => any ? T : enver} args
+   *
+   * @remarks
+   * This is for all intents and purposes the same as our client render method.
+   * `plainRender` is already wrapped in act().
+   * However, React has a bug that flushes effects in a portal synchronously.
+   * We have to defer the effect manually like `useEffect` would so we have to flush the effect manually instead of relying on `act()`.
+   * React bug: https://github.com/facebook/react/issues/20074
+   */
+  function render(...args) {
+    const result = clientRender(...args);
+    clock.next();
+    return result;
+  }
 
   before(() => {
     classes = getClasses(<Snackbar open />);
@@ -44,16 +72,6 @@ describe('<Snackbar />', () => {
   });
 
   describe('Consecutive messages', () => {
-    let clock;
-
-    before(() => {
-      clock = useFakeTimers();
-    });
-
-    after(() => {
-      clock.restore();
-    });
-
     it('should support synchronous onExited callback', () => {
       const messageCount = 2;
       let view;
@@ -99,16 +117,6 @@ describe('<Snackbar />', () => {
   });
 
   describe('prop: autoHideDuration', () => {
-    let clock;
-
-    before(() => {
-      clock = useFakeTimers();
-    });
-
-    after(() => {
-      clock.restore();
-    });
-
     it('should call onClose when the timer is done', () => {
       const handleClose = spy();
       const autoHideDuration = 2e3;
@@ -241,16 +249,6 @@ describe('<Snackbar />', () => {
   });
 
   describe('prop: resumeHideDuration', () => {
-    let clock;
-
-    before(() => {
-      clock = useFakeTimers();
-    });
-
-    after(() => {
-      clock.restore();
-    });
-
     it('should not call onClose with not timeout after user interaction', () => {
       const handleClose = spy();
       const autoHideDuration = 2e3;
@@ -323,16 +321,6 @@ describe('<Snackbar />', () => {
   });
 
   describe('prop: disableWindowBlurListener', () => {
-    let clock;
-
-    before(() => {
-      clock = useFakeTimers();
-    });
-
-    after(() => {
-      clock.restore();
-    });
-
     it('should pause auto hide when not disabled and window lost focus', () => {
       const handleClose = spy();
       const autoHideDuration = 2e3;