Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ShadowDOM] Modal doesn't work well with ShadowRoot #16223

Closed
1 task done
Jack-Works opened this issue Jun 14, 2019 · 11 comments
Closed
1 task done

[ShadowDOM] Modal doesn't work well with ShadowRoot #16223

Jack-Works opened this issue Jun 14, 2019 · 11 comments
Labels
component: modal This is the name of the generic UI component, not the React module! new feature New feature or request

Comments

@Jack-Works
Copy link
Contributor

  • I have searched the issues of this repository and believe that this is not a duplicate.

Expected Behavior 馃

Everything works well.

Current Behavior 馃槸

With 3 hacks, it partially works. But all events lost (like onClick)

(In this example, CSS also lost too but it is not the key problem. I have resolved this problem in my context, by implementing a custom JSS renderer to render the style into the ShadowRoot)

Steps to Reproduce 馃暪

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/@material-ui/core@latest/umd/material-ui.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>

<body>
    <div id="root"></div>
    <div id="portalhost"></div>
</body>
<script>
    var shadow = root.attachShadow({ mode: 'closed' })
    var portal = portalhost.attachShadow({ mode: 'closed' })
    var { Dialog } = MaterialUI
    // ? Mui use ReactDOM.findDOMNode
    // it doesn't work well with ShadowRoot
    // This is a hack
    Object.defineProperty(ShadowRoot.prototype, 'nodeType', {
        get() {
            if (this === portal) return 1 // if not return 1, findDOMNode will think this is a React Component instance.
            else return 11
        }
    })
    Object.defineProperty(ShadowRoot.prototype, 'tagName', {
        get() {
            if (this === portal) return 'div' // will try to invoke .tagName.toLowercase() on this Node
            else return undefined
        }
    })
    // ? Mui set .style on the container and it isn't exists on ShadowRoot
    // Forwarding it to the host of the ShadowRoot
    Object.defineProperty(ShadowRoot.prototype, 'style', {
        get() {
            if (this === portal) return this.host
            else return undefined
        }
    })
</script>
<script type="text/babel">
    ReactDOM.render(<App />, shadow)
    function App() {
        return (
            <article onClick={e => console.log('article', e)}>
                Hello, world
                <br />
                <Dialog open container={portal} onClick={e => console.log('dialog', e)}>
                    <Component />
                </Dialog>
            </article>
        )
    }
    function Component() {
        return <h1 onClick={e => console.log('h1', e)}>Click me</h1>
    }
</script>

Context 馃敠

I'm developing a crypto-related browser extension. I need to render everything in ShadowRoot to prevent the host webpage get any info from my extension.

Your Environment 馃寧

Tech Version
Material-UI latest
React 16
Browser Chrome 74
TypeScript N/A
@Jack-Works
Copy link
Contributor Author

And ReactDOM.createPortal works well.

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>

<body>
    <div id="root"></div>
    <div id="portalhost"></div>
</body>
<script>
    var shadow = root.attachShadow({ mode: 'closed' })
    var portal = portalhost.attachShadow({ mode: 'closed' })
</script>
<script type="text/babel">
    ReactDOM.render(<App />, shadow)
    function App() {
        return (
            <article onClick={() => alert('article')}>
                Hello, world
                <br />
                {ReactDOM.createPortal(<Component />, portal)}
            </article>
        )
    }
    function Component() {
        return <h1 onClick={() => alert('h1')}>Click me</h1>
    }
</script>

@oliviertassinari oliviertassinari added the component: modal This is the name of the generic UI component, not the React module! label Jun 14, 2019
@oliviertassinari
Copy link
Member

We have a similar issue in #16191.

@oliviertassinari oliviertassinari added the new feature New feature or request label Jun 14, 2019
@Jack-Works
Copy link
Contributor Author

Okay, I resolved this by more hacks. But still nice to see this problem get resolved.

<body>
    <div id="root"></div>
    <div id="portalhost"></div>
</body>
<script>
    var shadow = root.attachShadow({ mode: 'closed' })
    var portal = portalhost.attachShadow({ mode: 'closed' })

    {
        // ? Hack for React, let event go through ShadowDom
        const hackingEvents = new WeakMap()
        function hack(eventName, shadowRoot) {
            shadowRoot.addEventListener(eventName, e => {
                if (hackingEvents.has(e)) return
                const path = e.composedPath()
                var e2 = new e.constructor(e.type, e)
                hackingEvents.set(e2, path)
                portal.dispatchEvent(e2)
                e.stopPropagation()
                e.stopImmediatePropagation()
            })
        }
        hack('click', portal)
        var nativeTarget = Object.getOwnPropertyDescriptor(Event.prototype, 'target').get
        Object.defineProperty(Event.prototype, 'target', {
            get() {
                if (hackingEvents.has(this)) return hackingEvents.get(this)[0]
                return nativeTarget.call(this)
            }
        })
    }

    // ? Mui use ReactDOM.findDOMNode
    // it doesn't work well with ShadowRoot
    // This is a hack
    Object.defineProperty(ShadowRoot.prototype, 'nodeType', {
        get() {
            if (this === portal) return 1
            else return Object.getOwnPropertyDescriptor(Node.prototype, 'nodeType').get.call(this)
        }
    })
    Object.defineProperty(ShadowRoot.prototype, 'tagName', {
        get() {
            if (this === portal) return 'div'
            else return undefined
        }
    })
    // ? Mui set .style on the container and it isn't exists on ShadowRoot
    // Forwarding it to the host of the ShadowRoot
    Object.defineProperty(ShadowRoot.prototype, 'style', {
        get() {
            if (this === portal) return this.host
            else return undefined
        }
    })
</script>

<script crossorigin src="https://unpkg.com/react@16/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@16/umd/react-dom.development.js"></script>
<script crossorigin src="https://unpkg.com/@material-ui/core@latest/umd/material-ui.development.js"></script>
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>

<script type="text/babel">
    var { Dialog } = MaterialUI

    ReactDOM.render(<App />, shadow)
    function App() {
        return (
            <article onClick={e => console.log('article', e)}>
                Hello, world
                <br />
                <Dialog open container={portal} onClick={e => console.log('dialog', e)}>
                    <Component />
                </Dialog>
            </article>
        )
    }
    function Component() {
        return <h1 onClick={e => console.log('h1', e)}>Click me</h1>
    }
</script>

@oliviertassinari
Copy link
Member

It's related to #17473

@oliviertassinari oliviertassinari changed the title Modal doesn't work well with ShadowRoot [ShadowDOM] Modal doesn't work well with ShadowRoot Sep 20, 2019
@Dizzzmas
Copy link

Having this issue right now. Got trouble understanding and applying the solution of @Jack-Works . Would appreciate any form of help

@Jack-Works
Copy link
Contributor Author

@Dizzzmas Hi, my example is cheating on the ShadowRoot DOM object and let it behave like a normal HTMLElement so the React will accept to render on it. Which part you don't understand?

@Dizzzmas
Copy link

Dizzzmas commented Jul 19, 2020

@Jack-Works
The part that I'm struggling with:

var nativeTarget = Object.getOwnPropertyDescriptor(Event.prototype, 'target').get
        Object.defineProperty(Event.prototype, 'target', {
            get() {
                if (hackingEvents.has(this)) return hackingEvents.get(this)[0]
                return nativeTarget.call(this)
            }
        })
    }

    // ? Mui use ReactDOM.findDOMNode
    // it doesn't work well with ShadowRoot
    // This is a hack
    Object.defineProperty(ShadowRoot.prototype, 'nodeType', {
        get() {
            if (this === portal) return 1
            else return Object.getOwnPropertyDescriptor(Node.prototype, 'nodeType').get.call(this)
        }
    })
    Object.defineProperty(ShadowRoot.prototype, 'tagName', {
        get() {
            if (this === portal) return 'div'
            else return undefined
        }
    })

I don't really understand when the above code is supposed to trigger and what is its purpose. I tried putting your solution into a React component's useEffect instead of <script> tags, but event handlers still weren't getting triggered.

I would really be grateful if you could provide a codeSandbox with this stuff working in a React functional component.

Here's the code that I was trying to use:

const MyDialog = () => {
  const classes = useStyles()

  const portalhost = document.createElement('div')
  portalhost.setAttribute('id', 'portalhost')
  document.body.prepend(portalhost)

  const portal = portalhost.attachShadow({ mode: 'open' })

  React.useEffect(() => {
    const modal = document.getElementsByClassName('MuiDialog-root')[0]

    if (modal) {
      const style = document.getElementById(`main-head-clone`)
      if (style) {
        const stylesClone = style.cloneNode(true)

        portal.append(modal)
        portal.append(stylesClone)

        const hackingEvents = new WeakMap()
        function hack(eventName: string, shadowRoot: ShadowRoot) {
          debugger
          portal.addEventListener('click', (e: any) => {
            debugger
            if (hackingEvents.has(e)) return
            const path = e.composedPath()
            debugger
            const e2 = new e.constructor(e.type, e)
            debugger
            hackingEvents.set(e2, path)
            shadowRoot.dispatchEvent(e2)
            e.stopPropagation()
            e.stopImmediatePropagation()
          })
        }
        // call hack with parameter1 = Event name you want to make it work with ShadowRoot
        // parameter2 = the shadow root

        hack('click', portal)

        const target = Object.getOwnPropertyDescriptor(Event.prototype, 'target')

        if (target) {
          const nativeTarget = target.get
          Object.defineProperty(Event.prototype, 'target', {
            get() {
              if (hackingEvents.has(this)) {
                return hackingEvents.get(this)[0]
              }
              if (nativeTarget) return nativeTarget.call(this)
            },
          })
        }
      }
    }
  })

  return (
    <Dialog open disableBackdropClick disableEscapeKeyDown onClick={(e) => console.log('dialog', e)}>
      <DialogContent className={classes.dialogContent}>
        <ErrorOutline className={classes.errorIcon} />
        <DialogContentText className={classes.dialogContentText}>
          <span>
            <p>
              <strong>My Extension</strong>
            </p>
            Please click the button.
          </span>
          <Button onClick={(e) => console.log('TEST', e)}>Click me</Button>
        </DialogContentText>
      </DialogContent>
    </Dialog>
  )
}

The Dialog is properly placed into the shadow-root and from the debugger window I can see that hackingEvents weakMap is being filled.

Screenshot 2020-07-19 at 13 46 06

@Jack-Works
Copy link
Contributor Author

@Dizzzmas here is our production level code https://github.com/DimensionDev/Maskbook/tree/master/src/utils/jss to render everything in ShadowDom, with JSS styling (injected into ShadowDom) suppoort

@cjoverbay
Copy link

Updated link to at least some of the source code @Jack-Works referenced - https://github.com/DimensionDev/Maskbook/tree/master/packages/maskbook/src/utils/shadow-root

Looks like we are missing some of the utility functions, not sure their package "maskbook-shared" is open source.

@Jack-Works
Copy link
Contributor Author

Updated link to at least some of the source code @Jack-Works referenced - https://github.com/DimensionDev/Maskbook/tree/master/packages/maskbook/src/utils/shadow-root

Looks like we are missing some of the utility functions, not sure their package "maskbook-shared" is open source.

Hi! We refactored our application a lot, the latest code is in https://github.com/DimensionDev/Maskbook/tree/master/packages/shared/src/ShadowRoot. It's ready for both JSS and emotion.

@Jack-Works
Copy link
Contributor Author

https://codesandbox.io/s/nostalgic-cloud-d2ny7?file=/src/Demo.tsx

All UI in the code sandbox above is rendered in the ShadowRoot. You can verify that by the devtools.

  • It can handle all emotion-based components (all MUI components in v5, and styled API).
  • It can also handle all JSS based components (all MUI components in v4 and makeStyles API).
  • It contains a usePortalShadowRoot hooks to handle Modals (which needs to render across different Shadow Roots).
const picker = usePortalShadowRoot((container) => (
     <DatePicker
         DialogProps={{ container }}
         PopperProps={{ container }}
         value={new Date()}
         onChange={() => {}}
         renderInput={(props) => <TextField {...props} />}
     />
))

Note: Our application is licensed in AGPLv3, you may not be able to reuse this code otherwise the license will spread into your project. This solution is quite hacky and might have a potential performance problem.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: modal This is the name of the generic UI component, not the React module! new feature New feature or request
Projects
None yet
Development

No branches or pull requests

4 participants