Source: data-engine-js-components-web/src/main/resources/META-INF/resources/js/utils/ReactComponentAdapter.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import IncrementalDomRenderer from 'metal-incremental-dom';
import JSXComponent from 'metal-jsx';
import {Config} from 'metal-state';
import React, {useEffect, useState} from 'react';
import ReactDOM from 'react-dom';

import Observer from './Observer.es';

const CONFIG_BLACKLIST = ['children', 'events', 'ref', 'visible'];
const CONFIG_DEFAULT = ['displayErrors'];

/**
 * The Adapter creates a communication bridge between the Metal and React components.
 * The Adapter when it is rendered for the first time uses `ReactDOM.render` to assemble
 * the component and subsequent renderings are done by React. We created a tunnel with
 * an Observer that updates the internal state of the component in React that makes a
 * wrapper over the main component to force React to render at the best time, we also
 * ignore the rendering of Metal.
 *
 * @example
 * // import getConnectedReactComponentAdapter from '/path/ReactComponentAdapter.es';
 * //
 * // const ReactComponent = ({children, className}) => <div className={className}>{children}</div>;
 * // const ReactComponentAdapter = getConnectedReactComponentAdapter(
 * //   ReactComponent
 * // );
 * //
 * // In the rendering of Metal
 * // render() {
 * //	return (
 * //		<ReactComponentAdapter className="h1-title">
 * //			<h1>{'Title'}</h1>
 * //		</ReactComponentAdapter>
 * //	);
 * // }
 *
 * To call the React component in the context of Metal + soy, where varient is not an option,
 * you can use Metal's `Soy.register` to create a fake component so that you can call the React
 * component in Soy. The use of children from Soy components for React does not work.
 *
 * @example
 * // import Soy from 'metal-soy';
 * // import getConnectedReactComponentAdapter from '/path/ReactComponentAdapter.es';
 * // import templates from './FakeAdapter.soy';
 * //
 * // const ReactComponent = ({className}) => <div className={className} />;
 * // const ReactComponentAdapter = getConnectedReactComponentAdapter(
 * //   ReactComponent
 * // );
 * // Soy.register(ReactComponentAdapter, templates);
 * //
 * // In soy
 * // {call FakeAdapter.render}
 * //	{param className: 'test' /}
 * // {/call}
 *
 * @param {React.createElement} ReactComponent
 */

function getConnectedReactComponentAdapter(ReactComponent) {
	class ReactComponentAdapter extends JSXComponent {

		/**
		 * For Metal to track config changes, we need to config the state so
		 * that willReceiveProps is called as expected.
		 */
		constructor(config, parentElement) {
			const props = {};
			Object.keys(config)
				.concat(CONFIG_DEFAULT)
				.forEach((key) => {
					if (!CONFIG_BLACKLIST.includes(key)) {
						props[key] = Config.any().value(config[key]);
					}
				});

			super(config, parentElement);

			const data = JSXComponent.DATA_MANAGER.getManagerData(this);

			data.props_.configState({
				...JSXComponent.DATA,
				...props,
			});
		}

		created() {
			this.observer = new Observer();
			this.reactComponentRef = React.createRef();
		}

		disposed() {
			if (this.instance_) {
				ReactDOM.unmountComponentAtNode(this.instance_);
				this.instance_ = null;
			}
		}

		willReceiveProps(changes) {

			// Delete the events and children properties to make it easier to
			// check which values have been changed, events and children are
			// properties that are changing all the time when new renderings
			// happen, a new reference is created all the time.

			delete changes.events;
			delete changes.children;

			if (changes && Object.keys(changes).length > 0) {
				const newValues = {};
				const keys = Object.keys(changes);

				keys.forEach((key) => {
					if (!CONFIG_BLACKLIST.includes(key)) {
						newValues[key] = changes[key].newVal;
					}
				});

				this.observer.dispatch(newValues);
			}
		}

		/**
		 * Disable Metal rendering and let React render in the best
		 * possible way.
		 */
		shouldUpdate() {
			return false;
		}

		syncVisible(value) {
			this.observer.dispatch({visible: value});
		}

		render() {
			const {events, ref, store, ...otherProps} = this.props;

			/* eslint-disable no-undef */
			IncrementalDOM.elementOpen(
				'div',
				ref,
				[],
				'class',
				'react-component-adapter'
			);
			const element = IncrementalDOM.currentElement();
			IncrementalDOM.skip();
			IncrementalDOM.elementClose('div');
			/* eslint-enable no-undef */

			// eslint-disable-next-line @liferay/portal/no-react-dom-render
			ReactDOM.render(
				<ObserverSubscribe observer={this.observer}>
					<ReactComponent
						{...otherProps}
						{...events}
						{...store}
						instance={this}
						ref={this.reactComponentRef}
					/>
				</ObserverSubscribe>,
				element
			);

			this.instance_ = element;
		}
	}

	ReactComponentAdapter.RENDERER = IncrementalDomRenderer;

	return ReactComponentAdapter;
}

/**
 * Adds a sub observer to maintain the updated state of the
 * component.
 */
const ObserverSubscribe = ({children, observer}) => {
	const [state, setState] = useState({});

	useEffect(() => {
		const change = (value) => setState({...state, ...value});

		observer.subscribe(change);

		return () => {
			observer.unsubscribe(change);
		};
	}, [state, setState, observer]);

	return React.cloneElement(children, {
		...children.props,
		...state,
	});
};

export {getConnectedReactComponentAdapter};
export default getConnectedReactComponentAdapter;