import { Fragment, StrictMode } from "react";
import { type Root, createRoot } from "react-dom/client";
interface Options
{
/**
* Use strict mode. Defaults to `true`.
*/
strictMode?: boolean | undefined;
/**
* The mode of the shadow root.
*/
shadowMode?: ShadowRootMode | undefined;
/**
* Properties to be defined on the custom element.
*/
properties?: string[] | undefined;
/**
* Attributes to be defined on the custom element. The names
* should be lowercase.
*/
attributes?: string[] | undefined;
}
export default function reactToCustomElement
(
Component: React.ComponentType
,
{
strictMode = true,
shadowMode,
properties = [],
attributes = [],
}: Options
,
) {
// https://react.dev/blog/2024/04/25/react-19#support-for-custom-elements
class CustomElement extends HTMLElement {
static observedAttributes = [...attributes];
private container: HTMLElement | ShadowRoot;
private root: Root;
private connected = false;
private _componentProps = {} as P;
private _componentAttributes: Record = {};
private _componentEventHandlers = {};
constructor() {
super();
this.container = shadowMode
? this.attachShadow({ mode: shadowMode })
: this;
this.root = createRoot(this.container);
// React detects if the node has the corresponding properties otherwise
// props gets set as attributes.
for (const propertyName of properties) {
Object.defineProperty(this, propertyName, {
enumerable: true,
get() {
return this._componentProps[propertyName];
},
set(value) {
this._componentProps[propertyName] = value;
this.render();
},
});
}
}
connectedCallback() {
this.connected = true;
this.render();
}
disconnectedCallback() {
this.connected = false;
this.root.unmount();
}
attributeChangedCallback(attribute: string, _: string, value: string) {
this._componentAttributes[attribute] = value;
this.render();
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions | undefined,
): void {
for (const propName of properties) {
// Ignore setting custom event handlers for props named `on***`. Store the callback and pass it directly to the component.
// https://github.com/facebook/react/blob/9d4fba078812de0363fe9514b943650fa479e8af/packages/react-dom-bindings/src/client/DOMPropertyOperations.js#L189-L231
if (propName[0] === "o" && propName[1] === "n") {
const useCapture = propName.endsWith("Capture");
const eventName = propName.slice(
2,
useCapture ? propName.length - 7 : undefined,
);
if (eventName === type) {
// This is an event handler that user passed in.
// @ts-expect-error - TODO
this._componentEventHandlers[propName] = listener;
return;
}
}
}
super.addEventListener(type, listener, options);
}
private render() {
if (!this.connected) {
return;
}
const Wrapper = strictMode ? StrictMode : Fragment;
this.root.render(
,
);
}
}
return CustomElement;
}