Click event not firing when React Component in a Shadow DOM
Asked Answered
A

5

32

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

Azoth answered 16/6, 2016 at 17:57 Comment(2)
What is this special case you speak of? is it for experimentation only?Meir
@Meir 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.Azoth
A
26

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 =).

Azoth answered 17/6, 2016 at 22:53 Comment(6)
Great solution. Wonder why react team do not want to integrate it. I saw two PR about this.(Armallas
Will you "open-source" it? I found some bug (lots of dispatchEvent calls) and it will be nice to fix it and share.Armallas
Oh man wanna give you 10.000 upvotes on this. You saved my life.The
@Dimitry and josephnvu I fixed a bug, cleaned up the code and published this workaround at npmjs.com/package/react-shadow-dom-retarget-eventsThe
@The it was so long time ago, I do not remember.( That's the script I used gist.github.com/DimitryDushkin/c091d5a6c33e10641eef0828261d5398 but seems like there is a bug as awwester said.Armallas
Thank you @DimitriThe
T
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

The answered 9/8, 2017 at 13:40 Comment(0)
A
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.
Anility answered 8/6, 2021 at 5:32 Comment(0)
S
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.

Sacha answered 17/6, 2016 at 0:48 Comment(4)
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.Azoth
I found this. Maybe worth checking out.Sacha
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."Azoth
Figured out a solution. See above.Azoth
A
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?

Allonge answered 10/1, 2018 at 5:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.