DEV Community

Cover image for Enhancing Vanilla Web Components
Simon MacDonald for Begin

Posted on • Originally published at blog.begin.com

Enhancing Vanilla Web Components

In part one of this series we showed you how to include external third party web components into an Enhance application. The downside to these external components is that they are not server side rendered so they suffer from the dreaded flash of unstyled custom element (FOUCE) and if something goes wrong with JavaScript they won't be rendered at all!

In this post we will show you how to Enhance another Vanilla web component, wc-icon-rule, so that it arrives fully expanded on the client by avoiding the use of the Shadow DOM.

Editors note: Friends don't let friends use the Shadow DOM is a good idea for a future blog post. πŸ˜‰

wc-icon-rule

wc-icon-rule creates a horizontal rule for you with an image in the center that breaks up the line. The image is provided as a slot to the web component. This component is purely presentation with no client-side interactivity which will make the conversion to an Enhance component fairly simple.

Source code for wc-icon-rule

Conversion to an Enhance component

While wc-icon-rule is a small web component I'm still going to tackle the conversion as if it was a bigger, more complex, web component to show you how I would attack that challenge.

Step 1: Create a new Enhance component

First off we will need a new Enhance component to represent wc-icon-rule. To do this we need to create a new file named app/elements/wc-icon-rule.mjs. The contents of the file, to begin with, are:

export default function Element ({ html, state }) {
  return html``
}
Enter fullscreen mode Exit fullscreen mode

This is the base of every Enhance component.

Step 2: Add the current wc-icon-rule

Next we'll take the source code for wc-icon-rule and wrap it in a script tag in our html render function.

export default function Element ({ html, state }) {
  return html`
  <script type="module">
export class WCIconRule extends HTMLElement {
  constructor () {
    super()
    this.__shadowRoot = this.attachShadow({ mode: 'open' })
    const template = document.createElement('template')
    template.innerHTML = WCIconRule.template()
    this.__shadowRoot.appendChild(template.content.cloneNode(true))
  }

  connectedCallback () {
    this.setAttribute('role', 'presentation')
    for (const child of this.children) {
      child.setAttribute('role', 'none')
    }
  }

  static template () {
    return \`
      <style>
        :host {
          display: block;
          overflow: hidden;
          text-align: center;
        }
        :host:before,
        :host:after {
          content: "";
          display: inline-block;
          vertical-align: middle;
          position: relative;
          width: 50%;
          border-top-style: var(--hr-style, solid);
          border-top-width: var(--hr-width, 1px);
          border-color: var(--hr-color, #000);
        }
        :host:before {
          right: var(--space-around, 1em);
          margin-left: -50%;
        }
        :host:after {
          left: var(--space-around, 1em);
          margin-right: -50%;
        }

        ::slotted(*) {
          display: inline-block;
          width: var(--width, 32px);
          height: var(--height, 32px);
          vertical-align: middle;
        }
      </style>
      <slot></slot>
    \`
  }
}

customElements.define('wc-icon-rule', WCIconRule)
</script>`
}
Enter fullscreen mode Exit fullscreen mode

Now we are rendering the component on the server but we are still sending the entire thing down as script tag so we haven't fixed the FOUCE issue.

Step 3: Extract your styles

The style tag for the component is rendered in the template function. This is perfectly fine but with Enhance's ability to hoist styles to the head tag we can extract it from the script tag of the component.

So let's move that style tag above our script tag.

export default function Element ({ html, state }) {
  return html`
  <style>
    :host {
      display: block;
      overflow: hidden;
      text-align: center;
    }
    :host:before,
    :host:after {
      content: "";
      display: inline-block;
      vertical-align: middle;
      position: relative;
      width: 50%;
      border-top-style: var(--hr-style, solid);
      border-top-width: var(--hr-width, 1px);
      border-color: var(--hr-color, #000);
    }
    :host:before {
      right: var(--space-around, 1em);
      margin-left: -50%;
    }
    :host:after {
      left: var(--space-around, 1em);
      margin-right: -50%;
    }

    ::slotted(*) {
      display: inline-block;
      width: var(--width, 32px);
      height: var(--height, 32px);
      vertical-align: middle;
    }
  </style>
  <script type="module">
export class WCIconRule extends HTMLElement {
  constructor () {
    super()
    this.__shadowRoot = this.attachShadow({ mode: 'open' })
    const template = document.createElement('template')
    template.innerHTML = WCIconRule.template()
    this.__shadowRoot.appendChild(template.content.cloneNode(true))
  }

  connectedCallback () {
    this.setAttribute('role', 'presentation')
    for (const child of this.children) {
      child.setAttribute('role', 'none')
    }
  }

  static template () {
    return \`
      <slot></slot>
    \`
  }
}

customElements.define('wc-icon-rule', WCIconRule)
</script>`
}
Enter fullscreen mode Exit fullscreen mode

If you inspect your page in your browser dev tools, you will notice a style tag in the head tag of your page. The CSS rules in the style tag of the wc-icon-rule component have been hoisted to the pages style tag. The keen will notice that Enhance slightly re-writes your CSS rules so that :host becomes wc-icon-rule to properly target all wc-icon-rule's on your page.

Step 4: Remove the Shadow DOM

As mentioned earlier on in this post you don't need the Shadow DOM for a component like this one. Let's get rid of our dependency on the Shadow DOM.

First, delete the constructor function completely. We don't need it. Next, let's move <slot></slot> out of our template function and include it under the script tag. Finally, delete the rest of the template function as it is essentially a no-op now.

Your code should look like this:

export default function Element ({ html, state }) {
  return html`
  <style>
    :host {
      display: block;
      overflow: hidden;
      text-align: center;
    }
    :host:before,
    :host:after {
      content: "";
      display: inline-block;
      vertical-align: middle;
      position: relative;
      width: 50%;
      border-top-style: var(--hr-style, solid);
      border-top-width: var(--hr-width, 1px);
      border-color: var(--hr-color, #000);
    }
    :host:before {
      right: var(--space-around, 1em);
      margin-left: -50%;
    }
    :host:after {
      left: var(--space-around, 1em);
      margin-right: -50%;
    }

    ::slotted(*) {
      display: inline-block;
      width: var(--width, 32px);
      height: var(--height, 32px);
      vertical-align: middle;
    }
  </style>
  <script type="module">
export class WCIconRule extends HTMLElement {
  connectedCallback () {
    this.setAttribute('role', 'presentation')
    for (const child of this.children) {
      child.setAttribute('role', 'none')
    }
  }
}

customElements.define('wc-icon-rule', WCIconRule)
  </script>
  <slot></slot>`
}
Enter fullscreen mode Exit fullscreen mode

We'll still leave the connectedCallback function in place. We don't need the Shadow DOM anymore but we can still enhance our server side rendered web components with JavaScript to add interactive functionality.

Summary

I'm sure you would be able to handle compressing those four steps into a single step but I wanted to explicitly explain why we write components the way we do with Enhance.

While there is nothing inherently wrong with how the Vanilla JS Web Components are written, by modifying how they are delivered to the browser, you can avoid common web component problems like FOUCE - and reduce the overall JavaScript footprint on your page, which is important for performance and accessibility.

Top comments (5)

Collapse
 
jenc profile image
Jen Chan

If this is the Simon McDonald I think it is from Ottawa, hey hey hey!!! I needed to read this as I've been cram-learning web components as fast as I need to be building them for production! Over at my work we are using Fast.design :D

Collapse
 
macdonst profile image
Simon MacDonald

Hey, Jen it is indeed Simon from Ottawa. Ping me if you want to talk more about Enhance or just to catch up. I've got to get down to Toronto someday soon. Heck, maybe you should put me on the Toronto JS schedule so I have an excuse for going.

I will write about how to use Fast in an Enhance project soon. We think expanding your custom element on the server and avoiding the shadow DOM helps get around a bunch of issues with web components.

Collapse
 
jenc profile image
Jen Chan • Edited

I just realized why you might want to "remove" the Shadow DOM... in the case of wanting to style light-dom elements, right? Or is it because style encapsulation can lead to FOUCE?

Collapse
 
macdonst profile image
Simon MacDonald

We feel that Shadow DOM should be used as a last resort. By avoiding it's use until absolutely necessary you can get around the FOUCE issue, the problem with Shadow DOM and form elements, the a11y is better, if you SSR your components they work without JS which allows you to do true progressive enhancement.

Collapse
 
jenc profile image
Jen Chan

"Step 4: Remove the Shadow DOM" ... (Resists gut instinct scream)