WordPress / gutenberg

The Block Editor project for WordPress and beyond. Plugin is available from the official repository.
https://wordpress.org/gutenberg/
Other
10.34k stars 4.13k forks source link

React Error #321 for Custom Blocks #36219

Closed sehqlr closed 2 years ago

sehqlr commented 2 years ago

Description

I will begin by saying that I've been hitting my head against the wall on this, and I hope that I can get some help on this. I've opened up a support forum topic here and JedWatson/react-select#4893 there. I've gotten some help on IRC as well. But I've come to the conclusion that I need to open up this bug report as well.

My hypothesis is that there's a bug in Gutenberg that is triggered by a custom block using react-select's AsyncSelect for a dropdown within the InspectorControls component. This error only occurs when the Gutenberg plugin is active. It seems to be directly related to the upgrade to React 17. I don't want to get bit by this bug when Gutenberg core is upgraded to React 17, which is why I've been working on this for weeks now.

When the bug does trigger, the React error I get is this: https://reactjs.org/docs/error-decoder.html/?invariant=321 I've explored all of the possibilities, and I think it might be the multiple copies of React one? But if I knew for sure, I wouldn't be opening this issue.

Step-by-step reproduction instructions

Unfortunately, there isn't an easy way for y'all to reproduce this bug, since the site I'm working on uses a custom theme and plugin, and both are proprietary. Hopefully the screenshots and snippets below explain this enough to diagnose.

Screenshots, screen recording, code snippet

Screenshots

This is what the editor looks like before the bug triggers. This is taken from my local dev env. Screenshot 2021-11-02 121722

This is what the editor looks like after you click on the CPT Archive custom block. This is production, with Gutenberg plugin deactivated. Screenshot 2021-11-02 122010

This is what happens when the Gutenberg plugin is active and you click on the block. Screenshot 2021-11-02 121818

Snippets

Like I said, I can't share everything since it's proprietary, but I will share two relevant files:

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;
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;

Environment info

Wordpress version: 5.8 Gutenberg version: 11.6 Custom theme and plugin are both version 0.1

Please confirm that you have searched existing issues in the repo.

Yes

Please confirm that you have tested with all plugins deactivated except Gutenberg.

Yes

sehqlr commented 2 years ago

Hey y'all, I was wondering if there was anything that I can do, or more information that I could provide, to help in solving this issue. Thanks!

montchr commented 2 years ago

I left a comment in https://github.com/JedWatson/react-select/issues/4893#issuecomment-1007551596 which might help – I ran into the same issue and found that I needed to add react and react-dom to my Webpack configuration's externals option as described in this article:

const webpackConfig = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'myplugin.build.js',
    libraryTarget: 'window',
  },
  externals: {
    'react': 'React',
    'react-dom': 'ReactDOM',
  },
};
ndiego commented 2 years ago

Given the solution that @montchr provided and the lack of recent activity, I am going to close this issue. If anyone feels it needs to be reopened, feel free to do so.