Building WebComponents for third-party sites using React, Typescript, Webpack and Element Queries

2 December 2019

Web components are a set of browser features promised to be a modern solution for encapsulating HTML fragments into custom HTML elements.

Their usage should replace the need for injecting external content into web pages using IFrames. But can we use them effectively with modern UI libraries like React?

The promises of WebComponents

  • Their javascripts and stylesheets can be encapsulated within ShadowDOM, thus avoiding interference with global DOM properties
  • ShadowDOM content can be rendered by browsers without putting it inside the main document DOM tree
  • Custom elements can be placed anywhere in the existing app’s structure
  • Third-party content can be embedded as WebComponent by simply adding a script and a custom HTML tag
  • The same web component can be reused on multiple target pages with minimal effort

Read also: Creating a Selenium grid

React with WebComponents

While browsing through the internet in search of React <-> Webcomponents integration patterns, one can find two kinds of problems being described:

  • Using webcomponents inside react components
  • Wrapping react components into webcomponents

In our case it was the latter. The task was to create a couple of independent widgets fully written in React, which could be used independently on multiple third-party target sites. We did not know anything about target sites’ layout, technologies used or web stack – the provided widgets had to be versatile and adapt to the container they would be put into and work fully independently.

We came up with the following architectural guidelines:

  • Each widget must be built and served separately (as single javascript + css file per widget).
  • Some parts of the code can be shared between widgets in the repository, but each widget’s bundle must contain only its own dependencies (in case of both javascripts and styles).
  • A single webpack build should generate all widgets at once, split into separate files.
  • Widget embedded on a third party page must adapt to the container’s width and be fully responsive.
  • The widget’s code cannot interfere with the base document and its appearance in any way.

Read also: Guava cache vs Caffeine

Integrating React components into webcomponents

First things first, we created a base WebComponent class BaseWebComponent. Later, each of the widgets would be created as a separate class inherited from the BaseWebComponent. The goal was to put all the reusable code in one place – to keep things DRY.

import React from "react"
import ReactDOM from "react-dom"
import retargetEvents from "react-shadow-dom-retarget-events"

type Component = React.FunctionComponentElement<any>

export class BaseWebComponent extends HTMLElement {
  protected styleSheetUrl: string
  protected mountPoint: HTMLDivElement

  connectedCallback() {
    this.bootstrapShadowDOM(this.createReactComponent())
  }

  protected createReactComponent(): Component {
    throw new Error("Not implemented")
  }

  protected bootstrapShadowDOM(component: Component) {
    this.mountPoint = document.createElement("div")
    const shadowRoot = this.attachShadow({mode: "open"})
    shadowRoot.appendChild(this.mountPoint)

    this.createStyleSheet(shadowRoot)
    this.appendFontFaceStyleToDocument()

    this.renderReactApp(component)
    retargetEvents(shadowRoot)
  }

  protected appendFontFaceStyleToDocument() {
    const link = document.createElement("link")
    link.rel = "stylesheet"
    link.href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"

    document.head.appendChild(link)
  }

  protected createStyleSheet(shadowRoot: ShadowRoot) {
    const link = document.createElement("link")
    link.rel = "stylesheet"
    link.href = this.styleSheetUrl

    shadowRoot.appendChild(link)
  }

  protected renderReactApp(component: Component) {
    const app = <AppWrapper>{component}</AppWrapper>

    ReactDOM.render(app,this.mountPoint)
  }
}

Bootstrapping shadowDOM

Inside the connectedCallback method, which is a part of native API and is invoked while adding a custom element to the document, we’re calling a bootstrapShadowDOM function. It’s going to create a shadowRoot element with mountPoint – a node which will serve as a container for our root React component.

Adding stylesheets into shadowDOM

The createStyleSheet function adds a link tag to shadowRoot which points to a stylesheet url of a single widget (styleSheetUrl property is set on a concrete widget class level – discussed later).

Adding font references in CSS

We’ve found this one quite surprising. At first, our CSS file described in the previous step had contained @font-face declarations. However, although styles had been properly scoped inside shadowDOM, the @font-face declaration was simply ignored by the browser. We ended up with this little trick that we truly hoped wouldn’t happen – we had to append the stylesheet containing @font-face to the document head itself (check the appendFontFaceStyleToDocument method).

Finally, rendering the React component

Below you can see the source of a concrete widget inherited from BaseWebComponent:

import React from "react"
import {Widget, IProps} from "widgets/Widget/index"
import {cssFileUrl, BaseWebComponent} from "services/webcomponents"
import "assets/scss/app.scss" // styles common for all widgets
// widget-only styles are imported inside Widget itself and it's children

const OBSERVED_ATTR = "observed-attr"

export class WidgetWebComponent extends BaseWebComponent {
styleSheetUrl = cssFileUrl("widget")

static get observedAttributes() {
return [OBSERVED_ATTR]
}

attributeChangedCallback(attrName: string, _: string, newValue: string) {
if (!this.mountPoint) {
return
}

if (attrName === OBSERVED_ATTR) {
const props = this.buildProps({observedAttr: newValue})
this.renderReactApp(this.createReactComponent(props))
}
}

protected buildProps = ({observedAttr}: Partial): IProps => {
return {
observedAttr: observedAttr || this.getAttribute(OBSERVED_ATTR) || "",
}
}

protected createReactComponent(props: IProps = this.buildProps({})) {
return React.createElement(Widget, props, React.createElement("slot"))
}
}

window.customElements.define("widget-component", WidgetWebComponent)

The createReactComponent method simply creates a Widget element with given props. It’s later passed to the renderReactApp method (defined in BaseWebComponent) which renders a ReactDOM tree into the mount point.

Our web component <widget-component> accepts the attribute observedAttr, which needs to be passed to the React component. On the initial load, the attribute’s values are fetched with this.getAttribute(OBSERVED_ATTR). However, later on, the attribute changes need to be observed.

Observing attributes and propagating them to the React component

For this purpose custom elements native API has been used – it exposes the attributeChangedCallback method fired on each attribute change. If the changed attribute’s name matches, we take its new value and pass it to the buildProps method. Afterwards, a new React element instance is created and passed to the renderReactApp. It calls ReactDOM.render() once again on the same mount point – in this case the React diff mechanism mutates the DOM only as necessary to reflect the changes based on the new prop.

Webpack configuration

As we’ve previously stated, we wanted to get a separate .js and .css file for each widget. The way we’ve accomplished it is presented below. The WEBCOMPONENTS_DIR contains all of our widget files (the ones inherited from BaseWebComponent). The getEntries method retrieves all of them and returns an object in the form of [entryName]: entryPath. This way webpack processes, each one as an independent entry.
Using [name] in MiniCssExtractPlugin config let us create a css file per widget entry, containing styles for its dependencies only.

function getEntries() {
const baseEntries = {}

return fs.readdirSync(rootPath.join(WEBCOMPONENTS_DIR)).reduce((acc, fileName) => {
const [entryName] = fileName.split(".")
acc[entryName] = rootPath.join(`${WEBCOMPONENTS_DIR}/${fileName}`)

return acc
}, baseEntries)
}

module.exports = {
entry: getEntries(),
output: {
path: rootPath.join("dist"),
publicPath: "/",
},
plugins: [
new MiniCssExtractPlugin({
filename: 'assets/stylesheets/[name].css',
chunkFilename: 'assets/stylesheets/[name].css',
}),
...
],
...
}

The Responsiveness problem

WebComponents have their own encapsulated styles (which means nothing from outside of Shadow Dom should go in and nothing should go out) – they are independent of any styles not directly added to the WebComponent. However, their styles are not browser independent – if a WebComponent uses Media Queries, they are still Window dependent. And this is rather expected behaviour, since it would be odd if they worked any differently. But in our case, WebComponents needed to be parent-container dependent.
So the question arose: Can Media Queries be container dependent? Can Element change how it looks just because its parent differs in size? It turns out that it is possible.
Behold:

Element Queries

Element queries aren’t something natively available to all browsers. Actually there isn’t any draft whatsoever of such a concept in the official development of CSS. However, if you ask Google about Element Queries (or Container Queries) you will get quite a lot results on that topic. Some of them propose CSS-in-JS approach, some (that includes libraries) use data-attributes to create element-parent-width specific styles. In our project, we used some kind of mutation of the second approach. By monitoring Shadow Host width, we applied a certain class with breakpoint (“-small”, “-medium” or “-large”) to the topmost content container. This way we were able to create a proper CSS grid, based on the Foundation Flex Grid, that was responsive not to browser window size, but to the Shadow Host.

const TableBase: React.FC<IProps> = (props) => {
  const {header, subheader, rows, footer, responsiveClassName} = props

  const classList = classnames(
    "table",
    "eq-grid-container",
    {[responsiveClassName]: true},
  )

  return (
    <div className={classList}>
      <Space mx={4} display="flex" direction="column">
        <>
          <div className="table-header">
            {header}
          </div>
          {subheader && (
            <div className="table-subheader">
              {subheader}
            </div>
          )}
          <div className="table-content">
            {rows}
          </div>
          <div className="table-footer">
            {footer}
          </div>
        </>
      </Space>
    </div>
  )
}

export const Table: React.FC<IBaseProps> = (props) => (
  <ResponsiveContainer>
    {(responsiveClassName) => <TableBase {...props} responsiveClassName={responsiveClassName} />}
  </ResponsiveContainer>
)

What might not be seen clearly in the code example above is that the Foundation grid was overwritten only for the class “eq-container” that also had the aforementioned breakpoint class. This way, the Foundation grid could still be used without breaking anything and, on our responsive container, the specificity of classes was much higher than the basic Foundation specificity, therefore, base classes were always “second-best” for browser CSS interpreter.
“Whoa! Hold on for a second! How did you monitor the width of Shadow Host?” you may ask.
I’m very glad that you asked! It is time to introduce another element of our project. Let me present:

Resize Observer

If you take a look at the DIY implementations of Element Queries, you will find that some of them uses Mutations Observer as a way of monitoring specific element properties. And it is a very reasonable tactic, since Mutations Observer is quite a well-settled API and it works well on rather old versions of browsers and it works fully on IE11. It has quite good polyfills available, making it quite a useful thing. However, there is also one more relevant API available in this case. It is Resize Observer. Its name is more straightforward in terms of its usability – it checks if an observed element changes its dimensions or coordinates. After instantiating the observer on one or more elements and specifying a callback function that should run on change, it just sits there waiting for elements to change. Your main div gets smaller, because you are resizing your browser window? Boom, Resize Observer iterates through all the entries affected by the resize and returns something similar to getBoundingClientRect method – an object with dimensions and coordinates of the object after change. It is pretty easy to use and debug. So below is a full code of the BaseWebComponent that was mentioned before.

export class BaseWebComponent extends HTMLElement {
  protected styleSheetUrl: string
  protected mountPoint: HTMLDivElement
  private wrapperWidth: number
  private resObserver: ResizeObserver

  connectedCallback() {
    this.bootstrapShadowDOM(this.createReactComponent())
  }

  disconnectedCallback() {
    if (this.resObserver) {
      this.resObserver.disconnect()
    }
  }

  protected createReactComponent(): Component {
    throw new Error("Not implemented")
  }

  protected bootstrapShadowDOM(component: Component) {
    this.mountPoint = document.createElement("div")
    const shadowRoot = this.attachShadow({mode: "open"})
    shadowRoot.appendChild(this.mountPoint)

    this.setInitialWrapperWidth(shadowRoot)
    this.createResizeObserver(shadowRoot)
    this.createStyleSheet(shadowRoot)
    this.appendFontFaceStyleToDocument()

    this.renderReactApp(component)
    retargetEvents(shadowRoot)
  }

  protected setInitialWrapperWidth(shadowRoot: ShadowRoot) {
    const shadowRootParent = shadowRoot.host.parentElement

    this.wrapperWidth = shadowRootParent
      ? shadowRootParent.getBoundingClientRect().width
      : DEFAULT_WIDTH
  }

  protected appendFontFaceStyleToDocument() {
    const link = document.createElement("link")
    link.rel = "stylesheet"
    link.href = "https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"

    document.head.appendChild(link)
  }

  protected createStyleSheet(shadowRoot: ShadowRoot) {
    const link = document.createElement("link")
    link.rel = "stylesheet"
    link.href = this.styleSheetUrl

    shadowRoot.appendChild(link)
  }

  protected renderReactApp(component: Component) {
    const width = this.getCurrentWidth()

    const app = (
      <ResponsiveContext.Provider value={{width}}>
        {component}
      </ResponsiveContext.Provider>
    )

    ReactDOM.render(app, this.mountPoint)
  }

  protected getCurrentWidth() {
    return this.wrapperWidth
  }

  protected getShadowRootParentElementWidth = (element: ResizeObserverEntry[]) => {
    element.forEach((el) => {
      const nextWidth = el.contentRect.width

      if (this.wrapperWidth !== nextWidth) {
        this.wrapperWidth = nextWidth
        this.renderReactApp(this.createReactComponent())
      }
    })
  }

  protected createResizeObserver(shadowRoot: ShadowRoot) {
    const shadowRootParent = shadowRoot.host.parentElement

    this.resObserver = new ResizeObserver(this.getShadowRootParentElementWidth)
    if (shadowRootParent) {
      this.resObserver.observe(shadowRootParent)
    } else {
      console.error("shadowRootParent is not defined") // tslint:disable-line:no-console
    }
  }
}

There is one important downside of Resize Observer. It is quite a new API. It doesn’t work on IE11, on Edge or on Safari. It is available from Firefox 69 (at the time of writing, there was a public deployment of FF70 a couple of days ago). So, it’s not particularly available. However, people wanted to use Resize Observer RIGHT NOW! That’s why, there are really good ponyfills (for those not familiar with this term, ponyfill is a real thing! Go read about it here).
These ponyfills are working cross-browser implementations of documentation and even have proper TypeScript types. They just work. Which means that Resize Observer is usable cross-browser. There you go!

Passing the parent-width-props to React

Ok, so the Resize Observer did its job – it observed component resizing and returned the final value. But what is going to happen with that value? Well, I am glad you asked. The width value is passed to two different components:
1. A ResponsiveContext.Provider Component which is provided by a React-responsive library
2. A ResponsiveContainer which is our container designed to provide responsive classes for WebComponent Content.

Ad 1: React-responsive built-in ResponsiveContext.Provider is a great thing. It’s mostly useful, if you are using SSR – as it is written in documentation:

This should ease up server-side-rendering and testing in a Node environment

But, as it turns out, it is just as helpful for generating local width context for WebComponents. Thanks to providing a certain value of width, Media Query Component is not window-based anymore – it becomes Context-based and, therefore, uses breakpoints relatively to provided width, mounting and dismounting certain components based on parent width.

That is what a MediaQueries Component looks like:

interface IMediaQueriesProps {
 small?: JSX.Element
 medium?: JSX.Element
 large?: JSX.Element
 breakpoints: IBreakpoints
}

type RenderSmall = (breakpoints: IBreakpoints, small: JSX.Element, medium?: JSX.Element, large?: JSX.Element) => void
type RenderMedium = (breakpoints: IBreakpoints, medium: JSX.Element, large?: JSX.Element) => void
type RenderLarge = (breakpoints: IBreakpoints, large: JSX.Element) => void

const renderSmall: RenderSmall = (breakpoints, small, medium, large) => (
 <MediaQuery
   minWidth={0}
   maxWidth={medium ? breakpoints.mediumDown : large ? breakpoints.largeDown : undefined}
 >
   {small}
 </MediaQuery>
)

const renderMedium: RenderMedium = (breakpoints, medium, large) => (
 <MediaQuery
   minWidth={breakpoints.mediumUp}
   maxWidth={large ? breakpoints.largeDown : undefined}
 >
   {medium}
 </MediaQuery>
)

const renderLarge: RenderLarge = (breakpoints, large) => (
 <MediaQuery
   minWidth={breakpoints.largeUp}
 >
   {large}
 </MediaQuery>
)

const MediaQueries: React.FC<IMediaQueriesProps> = ({small, medium, large, breakpoints}) => (
 <>
   {small && renderSmall(breakpoints, small, medium, large)}
   {medium && renderMedium(breakpoints, medium, large)}
   {large && renderLarge(breakpoints, large)}
 </>
)

MediaQuery is a built-in component imported from react-responsive

Ad 2: ResponsiveContainer takes context-provided parent width and, through a simple function, provides first and foremost a presentational Component with class props containing a string with Element Query size classes, that I mentioned earlier.

That is what ResponsiveContainer looks like:

const DEFAULT_BREAKPOINTS: IBreakpoints = {
  smallUp: 320,
  mediumUp: 640,
  largeUp: 1024,
  smallDown: 319,
  mediumDown: 639,
  largeDown: 1023,
}

function getEqClassName(breakpoints: IBreakpoints = DEFAULT_BREAKPOINTS, width: number) {
  return (
    width < breakpoints.mediumUp ? "eq-small" :
    width < breakpoints.largeUp ? "eq-medium" :
    "eq-large"
  )
}

export const ResponsiveContainer: React.FC<IProps> = (props) => {
  const {width: widthString} = useContext(ResponsiveContext)
  const width = parseInt(widthString as string, 10)

  const responsiveClassProvider = () => getEqClassName(props.breakpoints, width)

  const [tableResponsiveClass, setTableResponsiveClass] = useState<string>(responsiveClassProvider())

  useEffect(() => {
    const nextResponsiveClass = responsiveClassProvider()

    if (tableResponsiveClass !== nextResponsiveClass) {
      setTableResponsiveClass(nextResponsiveClass)
    }
  }, [width])

  return props.children(tableResponsiveClass)
}

Wrapping Up

WebComponents are a promising technology that can surely find practical use cases. But if you’re willing to use them, keep in mind that they might give you some headaches and frustration – more or less depending on your use case.

WebComponents gave us a feeling of a technology that’s still kind of a work-in-progress, as we’ve found some of their problems not solved and had to come up with workarounds ourselves. Really though, making everything work as expected left us pretty satisfied.

Read also: Frontend Development

Cookies Policy

Our website uses cookies. You can change the rules for their use or block cookies in the settings of your browser. More information can be found in the Privacy Policy. By continuing to use the website, you agree to the use of cookies.
https://www.efigence.com/privacy-policy/