Repository: mui/material-ui (TypeScript)
Date: 2019-07-14 09:38:33+00:00
Task type: Bug Fix

Issue:
Portal: onRendered doesn't make sense if disablePortal is true
<!-- Checked checkbox should look like this: [x] -->
- [x] This is not a v0.x issue. <!-- (v0.x is no longer maintained) -->
- [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.

## Expected Behavior 🤔
I'm not sure if it's a design or a bug.
But I think this `onRendered` method should be allowed to be executed even if Portal is `disablePortal`. Otherwise, it can't be called a `onRendered` name(it's not inappropriate).

## Current Behavior 😯
<!---
    Describe what happens instead of the expected behavior.
-->

Portal: onRendered doesn't make sense if disablePortal is true.

## Steps to Reproduce 🕹
Link:https://codesandbox.io/s/material-demo-q46iu

1. just click the `MOUNT CHILDREN`
2. see dev-tools console 

## The Reason 
https://github.com/mui-org/material-ui/blob/783b6934632714a844b7d440e6d05729f9f2dc0c/packages/material-ui/src/Portal/Portal.js#L26

if disablePortal is true, it will not set mountNode.

https://github.com/mui-org/material-ui/blob/783b6934632714a844b7d440e6d05729f9f2dc0c/packages/material-ui/src/Portal/Portal.js#L34
and only if mountNode exist that onRendered function will call.

## 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  | latest  |
| React        |    latest   |
| Browser      |   chrome      |



Issue discussion:

Solution patch:
diff --git a/docs/pages/api/modal.md b/docs/pages/api/modal.md
--- a/docs/pages/api/modal.md
+++ b/docs/pages/api/modal.md
@@ -44,7 +44,7 @@ This component shares many concepts with [react-overlays](https://react-bootstra
 | <span class="prop-name">onBackdropClick</span> | <span class="prop-type">func</span> |  | Callback fired when the backdrop is clicked. |
 | <span class="prop-name">onClose</span> | <span class="prop-type">func</span> |  | Callback fired when the component requests to be closed. The `reason` parameter can optionally be used to control the response to `onClose`.<br><br>**Signature:**<br>`function(event: object, reason: string) => void`<br>*event:* The event source of the callback<br>*reason:* Can be:`"escapeKeyDown"`, `"backdropClick"` |
 | <span class="prop-name">onEscapeKeyDown</span> | <span class="prop-type">func</span> |  | Callback fired when the escape key is pressed, `disableEscapeKeyDown` is false and the modal is in focus. |
-| <span class="prop-name">onRendered</span> | <span class="prop-type">func</span> |  | Callback fired once the children has been mounted into the `container`. It signals that the `open={true}` prop took effect. |
+| <span class="prop-name">onRendered</span> | <span class="prop-type">func</span> |  | Callback fired once the children has been mounted into the `container`. It signals that the `open={true}` prop took effect.<br>This prop will be deprecated and removed in v5, the ref can be used instead. |
 | <span class="prop-name required">open&nbsp;*</span> | <span class="prop-type">bool</span> |  | If `true`, the modal is open. |
 
 The `ref` is forwarded to the root element.
diff --git a/docs/pages/api/portal.md b/docs/pages/api/portal.md
--- a/docs/pages/api/portal.md
+++ b/docs/pages/api/portal.md
@@ -22,7 +22,7 @@ that exists outside the DOM hierarchy of the parent component.
 | <span class="prop-name required">children&nbsp;*</span> | <span class="prop-type">node</span> |  | The children to render into the `container`. |
 | <span class="prop-name">container</span> | <span class="prop-type">union:&nbsp;object&nbsp;&#124;<br>&nbsp;func<br></span> |  | A node, component instance, or function that returns either. The `container` will have the portal children appended to it. By default, it uses the body of the top-level document object, so it's simply `document.body` most of the time. |
 | <span class="prop-name">disablePortal</span> | <span class="prop-type">bool</span> | <span class="prop-default">false</span> | Disable the portal behavior. The children stay within it's parent DOM hierarchy. |
-| <span class="prop-name">onRendered</span> | <span class="prop-type">func</span> |  | Callback fired once the children has been mounted into the `container`. |
+| <span class="prop-name">onRendered</span> | <span class="prop-type">func</span> |  | Callback fired once the children has been mounted into the `container`.<br>This prop will be deprecated and removed in v5, the ref can be used instead. |
 
 The component cannot hold a ref.
 
diff --git a/packages/material-ui/src/Modal/Modal.js b/packages/material-ui/src/Modal/Modal.js
--- a/packages/material-ui/src/Modal/Modal.js
+++ b/packages/material-ui/src/Modal/Modal.js
@@ -112,7 +112,13 @@ const Modal = React.forwardRef(function Modal(props, ref) {
     }
   });
 
-  const handleRendered = useEventCallback(() => {
+  const handlePortalRef = useEventCallback(node => {
+    mountNodeRef.current = node;
+
+    if (!node) {
+      return;
+    }
+
     if (onRendered) {
       onRendered();
     }
@@ -216,12 +222,7 @@ const Modal = React.forwardRef(function Modal(props, ref) {
   }
 
   return (
-    <Portal
-      ref={mountNodeRef}
-      container={container}
-      disablePortal={disablePortal}
-      onRendered={handleRendered}
-    >
+    <Portal ref={handlePortalRef} container={container} disablePortal={disablePortal}>
       {/*
           Marking an element with the role presentation indicates to assistive technology
           that this element should be ignored; it exists to support the web application and
@@ -327,8 +328,7 @@ Modal.propTypes = {
   /**
    * @ignore
    *
-   * A modal manager used to track and manage the state of open
-   * Modals. This enables customizing how modals interact within a container.
+   * A modal manager used to track and manage the state of open Modals.
    */
   manager: PropTypes.object,
   /**
@@ -351,6 +351,8 @@ Modal.propTypes = {
   /**
    * Callback fired once the children has been mounted into the `container`.
    * It signals that the `open={true}` prop took effect.
+   *
+   * This prop will be deprecated and removed in v5, the ref can be used instead.
    */
   onRendered: PropTypes.func,
   /**
diff --git a/packages/material-ui/src/Popper/Popper.js b/packages/material-ui/src/Popper/Popper.js
--- a/packages/material-ui/src/Popper/Popper.js
+++ b/packages/material-ui/src/Popper/Popper.js
@@ -4,7 +4,7 @@ import PopperJS from 'popper.js';
 import { chainPropTypes } from '@material-ui/utils';
 import Portal from '../Portal';
 import { createChainedFunction } from '../utils/helpers';
-import { useForkRef } from '../utils/reactHelpers';
+import { setRef, useForkRef } from '../utils/reactHelpers';
 
 /**
  * Flips placement if in <body dir="rtl" />
@@ -58,7 +58,7 @@ const Popper = React.forwardRef(function Popper(props, ref) {
     ...other
   } = props;
   const tooltipRef = React.useRef(null);
-  const handleRef = useForkRef(tooltipRef, ref);
+  const ownRef = useForkRef(tooltipRef, ref);
 
   const popperRef = React.useRef(null);
   const handlePopperRefRef = React.useRef();
@@ -81,10 +81,6 @@ const Popper = React.forwardRef(function Popper(props, ref) {
   }
 
   const handleOpen = React.useCallback(() => {
-    const handlePopperUpdate = data => {
-      setPlacement(data.placement);
-    };
-
     const popperNode = tooltipRef.current;
 
     if (!popperNode || !anchorEl || !open) {
@@ -96,6 +92,10 @@ const Popper = React.forwardRef(function Popper(props, ref) {
       handlePopperRefRef.current(null);
     }
 
+    const handlePopperUpdate = data => {
+      setPlacement(data.placement);
+    };
+
     const popper = new PopperJS(getAnchorEl(anchorEl), popperNode, {
       placement: rtlPlacement,
       ...popperOptions,
@@ -118,6 +118,14 @@ const Popper = React.forwardRef(function Popper(props, ref) {
     handlePopperRefRef.current(popper);
   }, [anchorEl, disablePortal, modifiers, open, rtlPlacement, popperOptions]);
 
+  const handleRef = React.useCallback(
+    node => {
+      setRef(ownRef, node);
+      handleOpen();
+    },
+    [ownRef, handleOpen],
+  );
+
   const handleEnter = () => {
     setExited(false);
   };
@@ -169,7 +177,7 @@ const Popper = React.forwardRef(function Popper(props, ref) {
   }
 
   return (
-    <Portal onRendered={handleOpen} disablePortal={disablePortal} container={container}>
+    <Portal disablePortal={disablePortal} container={container}>
       <div
         ref={handleRef}
         role="tooltip"
diff --git a/packages/material-ui/src/Portal/Portal.js b/packages/material-ui/src/Portal/Portal.js
--- a/packages/material-ui/src/Portal/Portal.js
+++ b/packages/material-ui/src/Portal/Portal.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import PropTypes from 'prop-types';
 import { exactProp } from '@material-ui/utils';
-import { useForkRef } from '../utils/reactHelpers';
+import { setRef, useForkRef } from '../utils/reactHelpers';
 
 function getContainer(container) {
   container = typeof container === 'function' ? container() : container;
@@ -19,8 +19,7 @@ const useEnhancedEffect = typeof window !== 'undefined' ? React.useLayoutEffect
 const Portal = React.forwardRef(function Portal(props, ref) {
   const { children, container, disablePortal = false, onRendered } = props;
   const [mountNode, setMountNode] = React.useState(null);
-  const childRef = React.useRef(null);
-  const handleRef = useForkRef(children.ref, childRef);
+  const handleRef = useForkRef(children.ref, ref);
 
   useEnhancedEffect(() => {
     if (!disablePortal) {
@@ -28,13 +27,22 @@ const Portal = React.forwardRef(function Portal(props, ref) {
     }
   }, [container, disablePortal]);
 
-  React.useImperativeHandle(ref, () => mountNode || childRef.current, [mountNode]);
+  useEnhancedEffect(() => {
+    if (mountNode && !disablePortal) {
+      setRef(ref, mountNode);
+      return () => {
+        setRef(ref, null);
+      };
+    }
+
+    return undefined;
+  }, [ref, mountNode, disablePortal]);
 
   useEnhancedEffect(() => {
-    if (onRendered && mountNode) {
+    if (onRendered && (mountNode || disablePortal)) {
       onRendered();
     }
-  }, [mountNode, onRendered]);
+  }, [onRendered, mountNode, disablePortal]);
 
   if (disablePortal) {
     React.Children.only(children);
@@ -65,6 +73,8 @@ Portal.propTypes = {
   disablePortal: PropTypes.bool,
   /**
    * Callback fired once the children has been mounted into the `container`.
+   *
+   * This prop will be deprecated and removed in v5, the ref can be used instead.
    */
   onRendered: PropTypes.func,
 };


Test patch:
diff --git a/packages/material-ui/src/Portal/Portal.test.js b/packages/material-ui/src/Portal/Portal.test.js
--- a/packages/material-ui/src/Portal/Portal.test.js
+++ b/packages/material-ui/src/Portal/Portal.test.js
@@ -47,21 +47,48 @@ describe('<Portal />', () => {
     });
   });
 
-  it('should have access to the mountNode', () => {
-    const refSpy1 = spy();
-    mount(
-      <Portal ref={refSpy1}>
-        <h1>Foo</h1>
-      </Portal>,
-    );
-    assert.deepEqual(refSpy1.args, [[null], [null], [document.body]]);
-    const refSpy2 = spy();
-    mount(
-      <Portal disablePortal ref={refSpy2}>
-        <h1 className="woofPortal">Foo</h1>
-      </Portal>,
-    );
-    assert.deepEqual(refSpy2.args, [[document.querySelector('.woofPortal')]]);
+  describe('ref', () => {
+    it('should have access to the mountNode when disabledPortal={false}', () => {
+      const refSpy = spy();
+      const wrapper = mount(
+        <Portal ref={refSpy}>
+          <h1>Foo</h1>
+        </Portal>,
+      );
+      assert.deepEqual(refSpy.args, [[document.body]]);
+      wrapper.unmount();
+      assert.deepEqual(refSpy.args, [[document.body], [null]]);
+    });
+
+    it('should have access to the mountNode when disabledPortal={true}', () => {
+      const refSpy = spy();
+      const wrapper = mount(
+        <Portal disablePortal ref={refSpy}>
+          <h1 className="woofPortal">Foo</h1>
+        </Portal>,
+      );
+      const mountNode = document.querySelector('.woofPortal');
+      assert.deepEqual(refSpy.args, [[mountNode]]);
+      wrapper.unmount();
+      assert.deepEqual(refSpy.args, [[mountNode], [null]]);
+    });
+
+    it('should have access to the mountNode when switching disabledPortal', () => {
+      const refSpy = spy();
+      const wrapper = mount(
+        <Portal disablePortal ref={refSpy}>
+          <h1 className="woofPortal">Foo</h1>
+        </Portal>,
+      );
+      const mountNode = document.querySelector('.woofPortal');
+      assert.deepEqual(refSpy.args, [[mountNode]]);
+      wrapper.setProps({
+        disablePortal: false,
+      });
+      assert.deepEqual(refSpy.args, [[mountNode], [null], [document.body]]);
+      wrapper.unmount();
+      assert.deepEqual(refSpy.args, [[mountNode], [null], [document.body], [null]]);
+    });
   });
 
   it('should render in a different node', () => {