31

I have a special case where I need to encapsulate a React Component with a Web Component. The setup seems very straight forward. Here is the React Code:

// React Component
class Box extends React.Component {
  handleClick() {
    alert("Click Works");
  }
  render() {
    return (
      <div 
        style={{background:'red', margin: 10, width: 200, cursor: 'pointer'}} 
        onClick={e => this.handleClick(e)}>

        {this.props.label} <br /> CLICK ME

      </div>
    );
  }
};

// Render React directly
ReactDOM.render(
  <Box label="React Direct" />,
  document.getElementById('mountReact')
);

HTML:

<div id="mountReact"></div>

This mounts fine and the click event works. Now when I created a Web Component wrapper around the React Component, it renders correctly but the click event doesn't work. Here is my Web Component Wrapper:

// Web Component Wrapper
class BoxWebComponentWrapper extends HTMLElement {
  createdCallback() {
    this.el      = this.createShadowRoot();
    this.mountEl = document.createElement('div');
    this.el.appendChild(this.mountEl);

    document.onreadystatechange = () => {
      if (document.readyState === "complete") {
        ReactDOM.render(
          <Box label="Web Comp" />,
          this.mountEl
        );
      }
    };
  }
}

// Register Web Component
document.registerElement('box-webcomp', {
  prototype: BoxWebComponentWrapper.prototype
});

And here is the HTML:

<box-webcomp></box-webcomp>

Is there something I'm missing? Or does React refuse to work inside a Web Component? I have seen a library like Maple.JS which does this sort of thing, but their library works. I feel like I'm missing one small thing.

Here is the CodePen so you can see the problem:

http://codepen.io/homeslicesolutions/pen/jrrpLP

josephnvu
  • 1,210
  • 1
  • 9
  • 14
  • What is this special case you speak of? is it for experimentation only? – Seth Jun 16 '16 at 19:06
  • @Seth Just a Proof of concept to see if we can encapsulate a React component in a Web Component so we can use it in some of our applications that doesn't have React as the main framework. It may be a far fetch approach, but just want to see if it's possible. – josephnvu Jun 16 '16 at 20:27

5 Answers5

25

As it turns out the Shadow DOM retargets click events and encapsulates the events in the shadow. React does not like this because they do not support Shadow DOM natively, so the event delegation is off and events are not being fired.

What I decided to do was to rebind the event to the actual shadow container which is technically "in the light". I track the event's bubbling up using event.path and fire all the React event handlers within context up to the shadow container.

I added a 'retargetEvents' method which binds all the possible event types to the container. It then will dispatch the correct React event by finding the "__reactInternalInstances" and seek out the respective event handler within the event scope/path.

retargetEvents() {
    let events = ["onClick", "onContextMenu", "onDoubleClick", "onDrag", "onDragEnd", 
      "onDragEnter", "onDragExit", "onDragLeave", "onDragOver", "onDragStart", "onDrop", 
      "onMouseDown", "onMouseEnter", "onMouseLeave","onMouseMove", "onMouseOut", 
      "onMouseOver", "onMouseUp"];

    function dispatchEvent(event, eventType, itemProps) {
      if (itemProps[eventType]) {
        itemProps[eventType](event);
      } else if (itemProps.children && itemProps.children.forEach) {
        itemProps.children.forEach(child => {
          child.props && dispatchEvent(event, eventType, child.props);
        })
      }
    }

    // Compatible with v0.14 & 15
    function findReactInternal(item) {
      let instance;
      for (let key in item) {
        if (item.hasOwnProperty(key) && ~key.indexOf('_reactInternal')) {
          instance = item[key];
          break;
        } 
      }
      return instance;
    }

    events.forEach(eventType => {
      let transformedEventType = eventType.replace(/^on/, '').toLowerCase();

      this.el.addEventListener(transformedEventType, event => {
        for (let i in event.path) {
          let item = event.path[i];

          let internalComponent = findReactInternal(item);
          if (internalComponent
              && internalComponent._currentElement 
              && internalComponent._currentElement.props
          ) {
            dispatchEvent(event, eventType, internalComponent._currentElement.props);
          }

          if (item == this.el) break;
        }

      });
    });
  }

I would execute the "retargetEvents" when I render the React component into the shadow DOM

createdCallback() {
    this.el      = this.createShadowRoot();
    this.mountEl = document.createElement('div');
    this.el.appendChild(this.mountEl);

    document.onreadystatechange = () => {
      if (document.readyState === "complete") {

        ReactDOM.render(
          <Box label="Web Comp" />,
          this.mountEl
        );

        this.retargetEvents();
      }
    };
  }

I hope this works for future versions of React. Here is the codePen of it working:

http://codepen.io/homeslicesolutions/pen/ZOpbWb

Thanks to @mrlew for the link which gave me the clue to how to fix this and also thanks to @Wildhoney for thinking on the same wavelengths as me =).

Matt Zeunert
  • 16,075
  • 6
  • 52
  • 78
josephnvu
  • 1,210
  • 1
  • 9
  • 14
11

I fixed a bug cleaned up the code of @josephvnu's accepted answer. I published it as an npm package here: https://www.npmjs.com/package/react-shadow-dom-retarget-events

Usage goes as follows

Install

yarn add react-shadow-dom-retarget-events or

npm install react-shadow-dom-retarget-events --save

Use

import retargetEvents and call it on the shadowDom

import retargetEvents from 'react-shadow-dom-retarget-events';

class App extends React.Component {
  render() {
    return (
        <div onClick={() => alert('I have been clicked')}>Click me</div>
    );
  }
}

const proto = Object.create(HTMLElement.prototype, {
  attachedCallback: {
    value: function() {
      const mountPoint = document.createElement('span');
      const shadowRoot = this.createShadowRoot();
      shadowRoot.appendChild(mountPoint);
      ReactDOM.render(<App/>, mountPoint);
      retargetEvents(shadowRoot);
    }
  }
});
document.registerElement('my-custom-element', {prototype: proto});

For reference, this is the full sourcecode of the fix https://github.com/LukasBombach/react-shadow-dom-retarget-events/blob/master/index.js

Lukas
  • 9,752
  • 15
  • 76
  • 120
7

This answer is an update from five years after.

Bad news: answer by @josephnvu (accepted at the moment of writing) and the react-shadow-dom-retarget-events package no longer work correctly, at least with React 16.13.1 - haven't tested with earlier versions. Looks like something was changed in React internals, causing the code to invoke the wrong listener callback.

Good news:

  • In React 16.13.1 (again, not tested with earlier 16.x), it's possible to render directly into shadow root, without intermediate blocks. In this case, listeners would be attached to the shadow root and not to the document, so React is able to capture and dispatch all events correctly. The obvious tradeoff is that you can't add anything else to the same shadow root, since React will overwrite your elements with rendered JSX.
  • In React 17, React attaches its listeners to the rendering root, not to the document or shadow root, so everything works out of the box, no matter where we render to.
Cerberus
  • 8,879
  • 1
  • 25
  • 40
0

Replacing this.el = this.createShadowRoot(); with this.el = document.getElementById("mountReact"); just worked. Maybe because react has a global event handler and shadow dom implies event retargeting.

mrlew
  • 7,078
  • 3
  • 25
  • 28
  • 1
    But i want to use the shadow dom. Otherwise it's not encapsulated. The challenge here is to have a React component Wrapped in a custom element. – josephnvu Jun 17 '16 at 04:50
  • 2
    I found [this](https://github.com/Wildhoney/ReactShadow). Maybe worth checking out. – mrlew Jun 17 '16 at 04:57
  • 2
    thanks @mrlew. This part helps: "As Shadow DOM has the concept of Event Retargeting for encapsulation purposes, event delegation will not function correctly because all events will appear to be coming from the Shadow DOM – therefore ReactShadow uses the React ID for each element to dispatch the event from the original element, therefore maintaining React's event delegation implementation." – josephnvu Jun 17 '16 at 06:08
  • Figured out a solution. See above. – josephnvu Jun 17 '16 at 22:56
0

I've discovered another solution by accident. Use preact-compat instead of react. Seems to work fine in a ShadowDOM; Preact must bind to events differently?

William Hilton
  • 2,613
  • 1
  • 15
  • 14