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’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[material-ui] Improve getChildRef() #43022

Open
wants to merge 23 commits into
base: master
Choose a base branch
from

Conversation

sai6855
Copy link
Contributor

@sai6855 sai6855 commented Jul 22, 2024

check #42818 (comment) for more context

@sai6855 sai6855 added package: utils Specific to the @mui/utils package enhancement This is not a bug, nor a new feature labels Jul 22, 2024
@mui-bot
Copy link

mui-bot commented Jul 22, 2024

Netlify deploy preview

https://deploy-preview-43022--material-ui.netlify.app/

Bundle size report

Details of bundle changes (Toolpad)
Details of bundle changes

Generated by 🚫 dangerJS against 0f10b63

Copy link
Member

@aarongarciah aarongarciah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments, but let's wait for @oliviertassinari since he suggested the improvements.

packages/mui-material/src/utils/getChildRef.js Outdated Show resolved Hide resolved
packages/mui-material/src/utils/getChildRef.js Outdated Show resolved Hide resolved
packages/mui-material/src/utils/getChildRef.js Outdated Show resolved Hide resolved
return null;
if (process.env.NODE_ENV !== 'production') {
if (!React.isValidElement(child)) {
console.error(
Copy link
Member

@oliviertassinari oliviertassinari Jul 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm missing something. Do we have a concrete example where this warning helps and is not harming (creating confusion)?

Copy link
Contributor Author

@sai6855 sai6855 Jul 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, console.error doesn't provide any help if child is invalid react element. Throwing error instead of logging error seems to improve DX.

Code used to get below errors

 <Slide direction="up" in={checked} mountOnEnter unmountOnExit>
        <p>sample</p>
        <p>sample</p>
  </Slide>

when just console.error is used
image

When thowing error instead of console.error

image

Copy link
Member

@oliviertassinari oliviertassinari Jul 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sai6855 I think this example

<Slide direction="up" in={checked} mountOnEnter unmountOnExit>
  <p>sample</p>
  <p>sample</p>
</Slide>

supports the previous argument I was trying to develop in #42818 (comment): we increase the bundle size for end-users, so we degrade the UX. But on the opposite side, the error is still not actionable, so we don't improve the DX.
I conclude that it makes things worse.

Previously, the experience was (without TypeScript):

SCR-20240723-bery

which feels equal.

So this didff looks like a step forward to me. Simpler, less bundle size, faster runtime:

diff --git a/packages/mui-material/src/utils/getChildRef.js b/packages/mui-material/src/utils/getChildRef.js
index a1d0c2aec5..c24cd926d3 100644
--- a/packages/mui-material/src/utils/getChildRef.js
+++ b/packages/mui-material/src/utils/getChildRef.js
@@ -1,9 +1,4 @@
-import * as React from 'react';
-
 export default function getChildRef(child) {
-  if (!child || !React.isValidElement(child)) {
-    return null;
-  }
   // 'ref' is passed as prop in React 19, whereas 'ref' is directly attached to children in React 18
   // below check is to ensure 'ref' is accessible in both cases
   return child.props.propertyIsEnumerable('ref') ? child.props.ref : child.ref;

Now, if we want to avoid the confusion for developers, to not have an error thrown, but only a console error message, I could see us doing. This feels like a better DX:

diff --git a/packages/mui-material/src/Slide/Slide.js b/packages/mui-material/src/Slide/Slide.js
index d669e811d6..5b3a7a71ed 100644
--- a/packages/mui-material/src/Slide/Slide.js
+++ b/packages/mui-material/src/Slide/Slide.js
@@ -119,6 +119,10 @@ const Slide = React.forwardRef(function Slide(props, ref) {
     ...other
   } = props;

+  // Invalid children provided, bailout
+  if (!React.isValidElement(children)) {
+    return children;
+  }
+
   const childrenRef = React.useRef(null);
   const handleRef = useForkRef(getChildRef(children), childrenRef, ref);

diff --git a/packages/mui-material/src/utils/getChildRef.js b/packages/mui-material/src/utils/getChildRef.js
index a1d0c2aec5..c24cd926d3 100644
--- a/packages/mui-material/src/utils/getChildRef.js
+++ b/packages/mui-material/src/utils/getChildRef.js
@@ -1,9 +1,4 @@
-import * as React from 'react';
-
 export default function getChildRef(child) {
-  if (!child || !React.isValidElement(child)) {
-    return null;
-  }
   // 'ref' is passed as prop in React 19, whereas 'ref' is directly attached to children in React 18
   // below check is to ensure 'ref' is accessible in both cases
   return child.props.propertyIsEnumerable('ref') ? child.props.ref : child.ref;

Now, there will also be the React 19 prop-types feedback problem for people now using types or using any:

SCR-20240723-bfup

There, I think that we simply need to create new utils, inspired by facebook/react#28328, and do:

diff --git a/packages/mui-material/src/Slide/Slide.js b/packages/mui-material/src/Slide/Slide.js
index d669e811d6..f316556579 100644
--- a/packages/mui-material/src/Slide/Slide.js
+++ b/packages/mui-material/src/Slide/Slide.js
@@ -1,6 +1,8 @@
 'use client';
 import * as React from 'react';
 import PropTypes from 'prop-types';
+import getDisplayName from '@mui/utils/getDisplayName';
+import checkPropTypes from 'prop-types/checkPropTypes';
 import { Transition } from 'react-transition-group';
 import chainPropTypes from '@mui/utils/chainPropTypes';
 import HTMLElementType from '@mui/utils/HTMLElementType';
@@ -82,11 +84,20 @@ export function setTranslateValue(direction, node, containerProp) {
   }
 }

+function muiCheckPropTypes(Component, props,) {
+  if (process.env.NODE_ENV === 'production') {
+    return;
+  }
+  return checkPropTypes(Component.propTypes, props, 'prop', getDisplayName(Component));
+}
+
 /**
  * The Slide transition is used by the [Drawer](/material-ui/react-drawer/) component.
  * It uses [react-transition-group](https://github.com/reactjs/react-transition-group) internally.
  */
-const Slide = React.forwardRef(function Slide(props, ref) {
+const Slide = React.forwardRef(function SlideIn(props, ref) {
+  muiCheckPropTypes(Slide, props);
+

cc @mui/code-infra

Copy link
Member

@Janpot Janpot Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a better DX:

I second that.

There, I think that we simply need to create new utils

Sure, I perhaps would consider moving the condition around muiCheckPropTypes(Slide, props); to shave off a few more bytes and prevent an unnecessary empty function call.

+const Slide = React.forwardRef(function SlideIn(props, ref) {
+  if (process.env.NODE_ENV !== 'production') {
+    muiCheckPropTypes(Slide, props);
+  }
+

Do we have a CI check that verifies certain utilities don't make it into production bundles?

Copy link
Member

@oliviertassinari oliviertassinari Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I perhaps would consider moving the condition around muiCheckPropTypes(Slide, props); to shave off a few more bytes and prevent an unnecessary empty function call.

Fair enough.

It's also missing a React.version check to not warn twice in React 18 and lower.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opportunity moved to #43138

sai6855 and others added 6 commits July 22, 2024 15:00
Co-authored-by: Olivier Tassinari <olivier.tassinari@gmail.com>
Signed-off-by: sai chand <60743144+sai6855@users.noreply.github.com>
@oliviertassinari oliviertassinari added the React 19 support PRs required to support React 19 label Jul 22, 2024
@oliviertassinari oliviertassinari changed the title [material-ui] refactor getChildRef util [material-ui] Improve getChildRef() Jul 22, 2024
@oliviertassinari
Copy link
Member

oliviertassinari commented Aug 1, 2024

@sai6855 The changes make sense to me now. We are back to a local maximum 👍.

If we want to get to the next local maximum, I would see:

diff --git a/packages/mui-material/src/Slide/Slide.js b/packages/mui-material/src/Slide/Slide.js
index d669e811d6..5b3a7a71ed 100644
--- a/packages/mui-material/src/Slide/Slide.js
+++ b/packages/mui-material/src/Slide/Slide.js
@@ -119,6 +119,10 @@ const Slide = React.forwardRef(function Slide(props, ref) {
     ...other
   } = props;

+  // Invalid children provided, bailout
+  if (!React.isValidElement(children)) {
+    return children;
+  }
+
   const childrenRef = React.useRef(null);
   const handleRef = useForkRef(getChildRef(children), childrenRef, ref);

combined with #43138, but better to be iterative.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Aug 1, 2024
@aarongarciah
Copy link
Member

@sai6855 as part of the React 19 effort, @DiegoAndai recently moved getChildRef to mui-utils under a new name: getReactNodeRef. Can you rebase the PR and apply the potential changes there?

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Aug 9, 2024
// 'ref' is passed as prop in React 19, whereas 'ref' is directly attached to children in older versions
return (element.props as any).propertyIsEnumerable('ref')
? (element.props as any).ref
return (element as any)?.props?.propertyIsEnumerable('ref')
Copy link
Contributor Author

@sai6855 sai6855 Aug 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure if type if element should be React.ReactNode or React.ReactElement, currently this util is used by Portal and portal supports multiple nodes as children

import getReactNodeRef from '@mui/utils/getReactNodeRef';

But if type of element remains as React.ReactNode we need to type case element to any to make element.props.propertyIsEnumerable('ref') work.

Where as if element type is React.ReactElement, type casting to any is not necessary as element.props won't result in type errors

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking into account that:

  • The recommended way to use this helper seems to check with React.isValidElement first (as per [material-ui] Improve getChildRef() #43022 (comment))
  • getReactNodeRef's behaviour goes like this:
    • returns the ref if the param is an element with a ref
    • returns null for elements without a ref and fragments
    • returns undefined for arrays.
    • throws an error when passing "non-object" children types (null, undefined, booleans, string, etc.)

Maybe it should be named getReactElementRef instead of getReactNodeRef? And update the param type to be React.ReactElement instead of React.ReactNode.

Here's a playground showcasing the differences between getReactElementRef and getReactNodeRef. Note how the former forces consumers to check with React.isValidElement before using it, which I think it's a better DX and avoids unexpected errors.

https://codesandbox.io/p/sandbox/getreactnoderef-zjcv2t?file=/src/App.tsx:73,21

Let me know if this makes sense or if I missed something.

cc @DiegoAndai since you worked on this helper, too.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic behind leaving the isValidElement check inside the util is that we were doing the check on most places in which we used the util so it seemed better to incorporate it into it (#43132 (comment)). I'm not against moving the check out, but I would like to understand what's the benefit of doing so. How is forcing consumers to check isValidElement better DX?

Copy link
Member

@oliviertassinari oliviertassinari Aug 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is forcing consumers to check isValidElement better DX?

@DiegoAndai I tried to demonstrate it in #43022 (comment). Most of the time, there is already a warning in place to flag when using the component API wrong. Silencing the error up in the the stack (not at getReactNodeRef) makes it easy for developers to clearly notice the problem and how to fix it.

if type if element should be React.ReactNode or React.ReactElement

+1 for React.ReactElement

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm on board with @oliviertassinari and @aarongarciah's proposed changes, lets go with the approach in #43022 (comment) @sai6855.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds great 👍.

Before we move forward, I think to sync with mui/base-ui#605 (comment) so we move in the same direction.

@sai6855 sai6855 marked this pull request as draft September 15, 2024 10:26
@sai6855
Copy link
Contributor Author

sai6855 commented Sep 15, 2024

Summary of changes done in this PR

  1. Removed !element || !React.isValidElement(element) check here, as it was decided it's better DX to move error up to component level here
  2. Added tests for getReactElementRef https://github.com/mui/material-ui/pull/43022/files#diff-55a56a78280026badefee15f47b8823274fe80e1ca3f4a8426c3ffb53d57c1a1 and https://github.com/mui/material-ui/pull/43022/files#diff-54bc148947b0c3f0a3e6872123e20b9615bf2940c1189ce27124597a5233746d
  3. Renamed getReactNodeRef to getReactElementRef
  4. Updated usage of getReactElementRef in Portal component here and here

@sai6855 sai6855 marked this pull request as ready for review September 15, 2024 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement This is not a bug, nor a new feature package: utils Specific to the @mui/utils package React 19 support PRs required to support React 19
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants