JedWatson / react-select

The Select Component for React.js
https://react-select.com/
MIT License
27.53k stars 4.12k forks source link

ForwardRef component errors out in custom WP block when Gutenberg plugin is active #4893

Closed sehqlr closed 2 years ago

sehqlr commented 2 years ago

Hey y'all,

I'm having a problem with a custom WordPress block that uses react-select and I have come to believe that this is a bug in react-select itself. I know that as a general rule, you shouldn't assume bugs are in the libraries that you use, but I've been at this for weeks and this is the best lead I have right now.

The backstory? My organization contracted with a third party to build out their new website. They are a WP shop, so naturally, they built a WP site. During development, they activated a beta version of Gutenberg (the page editor) to develop against upcoming features, but they left the beta version of Gutenberg on for some reason. (I wasn't there at the time.) Everything worked fine, until we updated WP to 5.8, then all of the sudden the custom blocks stopped working. I accidentally found the workaround of deactivating the beta of Gutenberg, and ever since then I've been working on figuring out what is happening.

Let's get to some concrete details. I'll start with screenshots:

Here is what my local environment looks like before the bug strikes: Screenshot 2021-11-02 121722 The "CPT Archive" component in the center of the screen is the custom block I'm using in my test page, but there are several blocks that break due to this bug.

This next screenshot is production, where the workaround has been applied and everything works as expected: Screenshot 2021-11-02 122010 If you look to the right, you'll see a panel with several controls for the block. This is where react-select is used, under the "Choose up to three posts" text. I'll post the source code for this component below these screenshots.

Here is the final screenshot, where things go wrong... Screenshot 2021-11-02 121818 As soon as you click on the block, when the side panel with the react-select component tries to mount, it completely breaks the custom block. If you look at the console log, there's a mention of the <ForwardRef> component erroring out.

I can't post the entire codebase here, but I will post two React components. The first one is called PostTypePicker:

import AsyncSelect from 'react-select/async';
import ResourcePicker from './ResourcePicker';

const { wp } = window;
const { Component } = wp.element;
const { apiFetch } = wp;

/**
 * PostPicker is the react component for building React Select based
 * post pickers.
 *
 * @param post_types
 */
class PostTypePicker extends ResourcePicker {
        constructor(props) {
                console.log('PostTypePicker constructor');
                super(props);
                this.state = {
                        options: [],
                };
                this.loadOptions = this.loadOptions.bind(this);
        }

        componentDidMount() {
                const this2 = this;
                console.log('PostTypePicker componentDidMount');
                console.log(this2);
        }

        async loadOptions() {
                console.log('PostTypePicker loadOptions');
                const { postTypeLimit } = this.props;
                const self = this;
                return apiFetch({ path: '/washu/v1/posttypes' }).then((options) => {
                        return options
                                .filter((opt) => {
                                        if (!postTypeLimit) {
                                                return true;
                                        }
                                        return postTypeLimit.includes(opt.name);
                                })
                                .map((opt) => {
                                        const arr = [];
                                        arr.value = opt.name;
                                        arr.label = self.prettifyLabel(opt.label);

                                        return arr;
                                });
                });
        }

        /**
         * Extract necessary values and store in attr (for multiselects).
         *
         * TODO: Keeping for legacy purposes, will need tweaks when re-implementing multiselects
         *
         * @param types
         */
        handlePostTypeChange(types) {
                const post_type = types.map((type) => {
                        return type.value;
                });
                wp.setAttributes({ post_type });
        }

        /**
         * Take a snake/kebab-case slug and turn it into a nice pretty label
         *
         * @param label
         * @returns string
         */
        prettifyLabel(label) {
                const strip_pre = lodash.replace(label, /washu_/g, '');
                const spacify = lodash.replace(strip_pre, /[_|-]/g, ' ');

                if (['post', 'posts'].includes(spacify.toLowerCase())) {
                        return 'News';
                }

                return lodash.startCase(spacify);
        }

        /**
         * Regenerate value/label pairs from slug (for multiselects).
         *
         * @param post_types
         * @returns object
         */
        rehydratePostTypeSelection(post_types) {
                console.log('PostTypePicker rehydratePostTypeSelection');
                if (Array.isArray(post_types)) {
                        return post_types.map((type) => {
                                return {
                                        value: type,
                                        label: this.prettifyLabel(type),
                                };
                        });
                }
                return {
                        value: post_types,
                        label: this.prettifyLabel(post_types),
                };
        }

        render() {
                const multiple = this.props.isMulti;
                const handleChange = this.props.onChange;
                const { selected } = this.props;
                console.log('PostTypePicker rendering now');
                console.log(this);
                return (
                        <AsyncSelect
                                isMulti={multiple}
                                value={this.rehydratePostTypeSelection(selected)}
                                loadOptions={this.loadOptions}
                                defaultOptions
                                onChange={handleChange}
                        />
                );
        }
}

export default PostTypePicker;

The next one is called ResourcePicker:

import AsyncSelect from 'react-select/async';

const { wp } = window;
const { Component } = wp.element;
const { Spinner } = wp.components;

/**
 * ResourcePicker is the base react component for building React Select based
 * pickers. It uses a corresponding Resource object as the source of the data.
 */
class ResourcePicker extends Component {
        /**
         * Initializes the Resource Picker
         */
        constructor() {
                super(...arguments); // eslint-disable-line

                this.state = {
                        loaded: false,
                };

                this.onChange = this.onChange.bind(this);
                this.getResource = this.getResource.bind(this);
                this.idMapper = this.idMapper.bind(this);
        }

        /**
         * Fetch Selected Terms from the Resource
         */
        componentDidMount() {
                const self = this;
                const { initialSelection } = this.props;
                const resource = this.getResource(this.props);

                if (!resource.getSelection()) {
                        resource.loadSelection(initialSelection).then((results) => {
                                self.setState({ loaded: true });
                        });
                } else {
                        self.setState({ loaded: true });
                }

                console.log('ResourcePicker componentDidMount');
                console.log(self);
        }

        /**
         * Renders the ResourceSelect component
         *
         * @returns {object}
         */
        render() {
                if (!this.state.loaded) {
                        return <Spinner />;
                }

                const { isMulti } = this.props;
                const resource = this.getResource(this.props);

                console.log('ResourcePicker render');
                console.log(this);

                return (
                        <AsyncSelect
                                menuPortalTarget={document.body}
                                styles={{ menuPortal: (base) => ({ ...base, zIndex: 99999 }) }}
                                isMulti={isMulti}
                                isClearable
                                defaultValue={resource.getSelection()}
                                loadOptions={resource.getFinder()}
                                defaultOptions
                                onChange={this.onChange}
                                getOptionLabel={resource.getOptionLabel()}
                                getOptionValue={resource.getOptionValue()}
                        />
                );
        }

        /**
         * Updates the data sources connected to this Resource Select
         *
         * @param {object} selection The new selection
         * @param {object} opts Optional opts
         */
        onChange(selection, { action }) {
                console.log('ResourcePicker onChange');
                const { onChange } = this.props;

                if (!onChange) {
                        return;
                }

                switch (action) {
                        case 'select-option':
                        case 'remove-value':
                                onChange(this.serializeSelection(selection));
                                break;

                        case 'clear':
                                onChange(this.serializeSelection(null));
                                break;

                        default:
                                break;
                }
        }

        /**
         * Saves the selection in the Control attributes
         *
         * @param {object} selection The new selection
         * @returns {string}
         */
        serializeSelection(selection) {
                const { setAttributes } = this.props;

                const multiple = this.props.isMulti;
                const resource = this.getResource(this.props);

                let picks;

                if (multiple) {
                        const values = selection
                                ? lodash.map(selection, this.props.idMapper || this.idMapper)
                                : [];

                        resource.selectedIDs = values;
                        resource.selection = selection || [];

                        picks = lodash.join(values, ',');
                } else {
                        const value = selection ? selection.id : '';

                        resource.selectedIDs = selection ? [selection.id] : [];
                        resource.selection = selection ? [selection] : [];

                        picks = `${value}`;
                }

                return picks;
        }

        /**
         * Lazy initializes the resource object.
         *
         * @param {object} props The component props.
         * @returns {object}
         */
        getResource(props) {
                /* abstract */
                return null;
        }

        /**
         * Extracts item id from item.
         *
         * @param {object} item The item to map
         * @returns {number}
         */
        idMapper(item) {
                return item.id || false;
        }
}

export default ResourcePicker;

As you can see in the code, I've added calls to console.log. For the particular CPT Archive component, it uses PostTypePicker (the first one). The error happens after PostTypePicker.render() is called, but before PostTypePicker.componentDidMount(). So, this leads me to believe that the error about <ForwardRef> is happening within AsyncSelect.render() or similar.

An additional detail: when the stable version of Gutenberg is enabled, it uses React 16.13.x. When the beta version of Gutenberg is enabled (when the bug triggers) it uses React 17.0.x.

Sorry this turned into a book of a bug report. I'm pretty much stumped on this one, and I'm hoping that it's just something obvious I missed because this particular area is new to me.

Thanks in advance, Sam

ebonow commented 2 years ago

@sehqlr

Before digging too much into this, here is the error that is being reported in your console: https://reactjs.org/docs/error-decoder.html/?invariant=321

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: 1. You might have mismatching versions of React and the renderer (such as React DOM) 2. You might be breaking the Rules of Hooks 3. You might have more than one copy of React in the same app See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.

None of the errors listed there seems related to react-select. My guess would be that if the beta of Gutenberg is using v17, then it could be possible that all of the references of React aren't being updated to v17 to match.

Would you kindly take a look at your lock file to ensure your project is only importing one version of react and react-dom?

sehqlr commented 2 years ago

Hey @ebonow, thanks for your question. All of this functionality is implemented in a WP plugin. That plugin's package.json doesn't mention react or react-dom directly; the Component class is being loaded from WP's element package.

Now, I have a webpack noob question: If react-select depends on react, does that copy of react get included in a webpack build? Because if it does, that would explain a few things.

ebonow commented 2 years ago

@sehqlr did a bit more research on this and did come across the issue you logged on wordpress.

My understanding on the Gutenberg plugin is that it is an abstraction wrapper over React so no other React dependencies are needed or should be imported. In the case of react-select, React is listed as a peer-dependency which means that it's not included within the package, but it is still a dependency for react-select to work.

I created a Wordpress account and will likely add on to that support thread you created as it seems to be more related to the handlings of react as opposed to any internal functioning of the library, but before commenting there, thought to ask what version of react-select are you using in this project?

sehqlr commented 2 years ago

@ebonow You are going above and beyond, and I appreciate your work on helping me on this. The version of react-select is 5.1.0.

weswil07 commented 2 years ago

Hey, just want to mention that I have noticed the same error in an application I am working on. This is a Python app with React on the front end, and is being installed by another Python/React app using pip. I was able to get rid of the error by downgrading react-select to version 3.2.0. React-select 4.0.0 is the earliest version that this problem appears for us.

ebonow commented 2 years ago

I was able to get rid of the error by downgrading react-select to version 3.2.0. React-select 4.0.0 is the earliest version that this problem appears for us.

@weswil07 React-Select v4 was a focus on removing the deprecated lifecycle methods so that it would be compatible with React v17 as well as an update to Emotion v11.

One suspicion I have is that using Emotion to translate the JSX means that there might be some conflict involved there, but I'm too unexperienced with both Emotion and v17 to say so with any confidence.

weswil07 commented 2 years ago

@ebonow That would make sense. In version 5 the error stems from 'ForwardRef' but in version 4 the error stems from 'EmotionCssPropInternal'.

montchr commented 2 years ago

I recently ran into the same issue in a custom block after updating react-select from v3 to v5. In my case, the fix turned out to be extremely simple though it took quite some time to arrive there 😅 …

Because Gutenberg provides its own versions of React and ReactDOM, we have to tell third-party libraries about those versions by mapping the package names to the global variables available in the block editor.

In a Webpack setup, that's as simple as adding the mappings to the Webpack configuration's externals option:

  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },

In this case, react/react-dom are the package names (i.e. npm install <package-name>), and React/ReactDOM correspond to the window.React and window.ReactDOM global variables.

Credit goes to this article for pointing me in the right direction.

Does that help with your issue @sehqlr?

RavishaHeshanE25 commented 2 years ago

I recently ran into the same issue in a custom block after updating react-select from v3 to v5. In my case, the fix turned out to be extremely simple though it took quite some time to arrive there sweat_smile …

Because Gutenberg provides its own versions of React and ReactDOM, we have to tell third-party libraries about those versions by mapping the package names to the global variables available in the block editor.

In a Webpack setup, that's as simple as adding the mappings to the Webpack configuration's externals option:

  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },

In this case, react/react-dom are the package names (i.e. npm install <package-name>), and React/ReactDOM correspond to the window.React and window.ReactDOM global variables.

Credit goes to this article for pointing me in the right direction.

Does that help with your issue @sehqlr?

Thank you @montchr You saved my day.

Rall3n commented 2 years ago

@montchr solution is the correct way to fix this issue.

I will close this as there being a solution available.

mevanloon commented 2 years ago

I would request this be reopened. I have the same issue, but a different subset: I use react-select in a component that I use within a Gutenberg, but also on the front-end with a 'regular' React.

Previous versions have worked just fine in tandem with Gutenberg, but trying the latest major updates (4.x, 5.x) gives me tens of 'ForwardRef' / Invalid hook call errors when using the same component in gutenberg. Sidenote: 4.x gives me tens of EmotionCSS errors, rather than forwardref. 3.x works fine for me.

RavishaHeshanE25 commented 2 years ago

I would request this be reopened. I have the same issue, but a different subset: I use react-select in a component that I use within a Gutenberg, but also on the front-end with a 'regular' React.

Previous versions have worked just fine in tandem with Gutenberg, but trying the latest major updates (4.x, 5.x) gives me tens of 'ForwardRef' / Invalid hook call errors when using the same component in gutenberg. Sidenote: 4.x gives me tens of EmotionCSS errors, rather than forwardref.

@mevanloon I'm also using this as you described. Actually, when we understand the cause of the issue the solution is simple. The error occurs when the react version used by react-select is different from the one in Gutenberg. By providing react and react-dom as externals to the build context, Webpack uses the same react version that was added to the window object by the Gutenberg. As long as Gutenberg and react-select use two different react versions, it will throw errors.

mevanloon commented 2 years ago

It makes sense to think that, but I don't think differing React versions is the issue here. I'm using React 17.x to build all my components -- components that all get used in the Gutenberg editor, but also on the front-end. Because I use import React from 'react' in my components, it uses that version for the eventual compiled script -- even in Gutenberg. Gutenberg is also using react version 17.x for the past year. Not to mention that react-select 3.x is working perfectly fine for me, should that be more of an issue, regarding version numbers?

I did have a slight issue with implementing useState in my components, and had to check if a component was running in a Gutenberg context; if it was, I used wp.element.useState.

let createState = typeof wp != "undefined" ? wp.element.useState : useState

Anyway, this is probably all because these react components are really not made for Gutenberg (which is it's own little ecosystem), but it's weird to me how many React components do work (like react-select 3.x, for one), even while using their own React object, rather than Gutenberg's.

RavishaHeshanE25 commented 2 years ago

@mevanloon "I'm using React 17.x to build all my components" this is the issue. In package.json of react-select(even in the latest version and master branch) you can see that react 16 is a dependency. so for react-select, it uses react 16 instead of 17 when you build without providing Gutenberg's react as an external

Rall3n commented 2 years ago

"I'm using React 17.x to build all my components" this is the issue. In package.json of react-select(even in the latest version and master branch) you can see that react 16 is a dependency. so for react-select, it uses react 16 instead of 17 when you build without providing Gutenberg's react as an external

@RavishaHeshanE25 The repository of react-select is a so called monorepo, a repository designed to house more than one package. The package.json file in the root of the project is for the repository, not the package as is. The package has its own package.json located under packages/react-select/package.json. There react is defined as a peerDependency.

It makes sense to think that, but I don't think differing React versions is the issue here.

@mevanloon There a three causes for this error listed in the explanation in the documentation.

  • You might have mismatching versions of React and React DOM.
  • You might be breaking the Rules of Hooks.
  • You might have more than one copy of React in the same app. React documentation

The last one is what is happening with Gutenberg. Without telling the bundler/compiler otherwise (e.g. externals in webpack) react-select and other dependencies inside your bundle will use React exports from a bundled version of React and not from the Gutenberg version.

In general React supports independent versions of itself on one page, but it "[...] breaks if require('react') resolves differently between the component and the react-dom copy it was rendered with".

RavishaHeshanE25 commented 2 years ago

@Rall3n sorry. it was a mistake. I saw that when I tried to find the cause for this issue. In my case, there were two react versions as well. 17 from Gutenberg and 16 from the bundle.