Khan / aphrodite

Framework-agnostic CSS-in-JS with support for server-side rendering, browser prefixing, and minimum CSS generation
5.34k stars 188 forks source link

Injecting style into a shadowDOM node? #194

Open brandonmp opened 7 years ago

brandonmp commented 7 years ago

I have a chrome extension that injects a content script into a shadow DOM node:

  window.addEventListener('load', () => {
    let injectDiv = document.createElement('div')
    const shadowRoot = injectDiv.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML =
      `<style>${require('raw-loader!app/styles/extension-material')}</style>
       <style data-aphrodite/>
       <div id='shadowReactRoot' />`

       document.body.appendChild(injectDiv)

        ReactDOM.render(
          <App />,
          shadowRoot.querySelector('#shadowReactRoot')
        )
      })
  })

The first style tag is for react-mdl components. This nicely containerizes all the requisite style in the Extension content script w/o leaking style into the parent page.

Aphrodite doesn't seem to like this, though. I followed the readme & tagged a style tag with data-aphrodite, but got this error:

invariant.js:44 Uncaught (in promise) Error: _registerComponent(...): 
Target container is not a DOM element.
(…)invariant @ invariant.js:44
_renderNewRootComponent @ ReactMount.js:311
_renderSubtreeIntoContainer @ ReactMount.js:401
render @ ReactMount.js:422
(anonymous function) @ index.js:88

I assume the problem is this snippet from https://github.com/Khan/aphrodite/blob/master/src/inject.js

const injectStyleTag = (cssContents /* : string */) => {
    if (styleTag == null) {
        // Try to find a style tag with the `data-aphrodite` attribute first.
        styleTag = document.querySelector("style[data-aphrodite]");

        // If that doesn't work, generate a new style tag.
        if (styleTag == null) {
            // Taken from
            // http://stackoverflow.com/questions/524696/how-to-create-a-style-tag-with-javascript
            const head = document.head || document.getElementsByTagName('head')[0];
            styleTag = document.createElement('style');

            styleTag.type = 'text/css';
            styleTag.setAttribute("data-aphrodite", "");
            head.appendChild(styleTag);
        }
    }

    if (styleTag.styleSheet) {
        // $FlowFixMe: legacy Internet Explorer compatibility
        styleTag.styleSheet.cssText += cssContents;
    } else {
        styleTag.appendChild(document.createTextNode(cssContents));
    }
};

Is there a way using the existing Aphrodite API to inject into the correct style tag, nested within the shadow DOM node?

ETA: things I've tried:

brandonmp commented 7 years ago

i've worked up a solution but not sure it's solid. basically: i start a MutationObserver that listens for data-aphrodite style to be injected in the head, then I take the textContent of data-aphrodite and inject it into my ShadowDOM

Does Aphrodite change the content of the style tag during run time? i.e., shoudl I also add a mutation observer for changes to the data-aphrodite tag?

// observer listens for nodes added to <head>
// (this code isn't tested but it works at first blush)
const findAphrodite = new MutationObserver((mutations, obs) => {
  mutations.forEach(mutation => {
    Array.from(mutation.addedNodes).forEach(n => {
      if (n.attributes.getNamedItem('data-aphrodite')) {

        // find shadow DOM style tag and inject
        document.querySelector('#extension')
          .shadowRoot.querySelector('#aphroditeStyle').textContent = n.textContent
         obs.disconnect()
      }
    })
  })
})

then modified the original injection script to:

  window.addEventListener('load', () => {
    let injectDiv = document.createElement('div')
    injectDiv.id = 'extension'
    const shadowRoot = injectDiv.attachShadow({ mode: 'open' })
    shadowRoot.innerHTML =
      `<style>${require('raw-loader!app/styles/extension-material')}</style>
       <div id='shadowReactRoot' />`

       document.body.appendChild(injectDiv)
       findAphrodite.observe(document.head, { childList: true })
        ReactDOM.render(
          <App />,
          shadowRoot.querySelector('#shadowReactRoot')
        )
      })
  })
xymostech commented 7 years ago

The original error you were seeing,

Target container is not a DOM element.
(…)invariant @ invariant.js:44
_renderNewRootComponent @ ReactMount.js:311
_renderSubtreeIntoContainer @ ReactMount.js:401
render @ ReactMount.js:422
(anonymous function) @ index.js:88

is because you forgot to close your <style> tag, so your <div id='shadowReactRoot' /> wasn't being added, and so React was complaining that you were trying to render to undefined. Closing your <style> tag at least makes the error go away, but doesn't fix your overall problem.

There's another issue that's sorta similar to this one, #130. I wonder if the suggested StyleSheet.renderInFrame(frame, () => { ... }) suggestion might work here as well? Then your code would turn out like...

    StyleSheet.renderInDocument(shadowRoot, () => {
        ReactDOM.render(
          <App />,
          shadowRoot.querySelector('#shadowReactRoot')
        )
    });

Maybe we could do something fancy to detect if we're in a shadow DOM or an iframe, so that we don't try to use the <head> tag.

brandonmp commented 7 years ago

@xymostech ah, i've spent too much time in jsx i think. i didn't forget to close, i just thought style could be self-closing (ie, <style data-aphrodite/>)

the mutation observer is doing the trick for now, but i'll give a look @ the renderInDocument approach if it starts to give me trouble.

xymostech commented 7 years ago

Okay! That API doesn't exist yet, it was just something I was thinking about a while back and never got to implementing. :P If I get around to it I'll mention it here, maybe you could try it out.