Skip to content

WIP: Try shadow DOM to fix CSS bleeding and improve re-usability of components

Denys Mishunov requested to merge dmishunov-experiment-shadowdom into master

This merge request suggests to give a try and discuss possibilities for using web components and in particular shadow DOM as a potential solution for the CSS issues discussed during the State of CSS call. In brief, we have a lot of CSS that we would like to make consistent, easier maintainable, autonomous, etc. There are a lot of possible solutions that are discussed in "Paradigm considerations" of the original discussion and this proposal ads one more potential solution for us to consider.

!!! NOTE !!! This proposal does not suggest switching to bare web components instead of Vue. It rather suggests a way of helping us write maintainable and easy to understand Vue components and applications.

What are we trying to solve and achieve?

The main goals are explained in the original discussion but to make things a bit more clear, the purpose of this proposal (among other things) is to limit the number of issues like (the list will be updated over time):

First of all, a very brief background on why shadow DOM might do better job for GitLab's needs over more conventional tools.

Naming methodologies like BEM, SMACSS, etc.

Such methodologies indeed proved to be useful in a lot of projects. But usually, in order to be useful, they should be implemented from the very beginning of a project and all team members following the methodology. These are methodologies based purely on naming conventions that means:

  • familiarity with the convention from all new team members (not immediately of course, but for a lot of members there's already plenty of things to learn when they join)
  • strict adherence to the convention in new and already-existing files (means update of both .vue AND .haml templates)
  • doesn't provide any encapsulation for styles, not allowing for proper components re-use
  • might be very fragile in large teams, hence requires some extensive testing on the CI level

Utility & component classes

Technically, this approach is just another way of applying a methodology to writing CSS. Hence everything that is said about 'naming methodologies' above could still be applicable here as well.

<style scoped> and CSS Modules in Vue components

<style scoped> is not what used to be a CSS specification proposal (supported by vendors in the beginning but retracted from browsers later). Neither is it something encapsulated: it simply generates random classes and adds a global <style> tag in the head. CSS Modules' work principle is very similar but provides more options for generating truly unique classes. Both approaches have similar set of pros/cons:

PROS:

  • Already available for Vue components
  • Provides scoping for component's styles
  • A lot of team members are familiar with at least CSS Modules approach from previous React projects

CONS:

  • Available only for Vue components. If we want to scope a component in HAML template that has not yet been converted into Vue component, we should look for a solution elsewhere.
  • Implementation of CSS Modules at this stage of the project means quite dramatic update of templates for all existing Vue components
  • While both techniques provide scoping, none of them provides encapsulation. This means that they do prevent component-specific styles from "leaking out" to the surrounding page, but don't prevent global styles from "leaking into" the component. Here are very basic but illustrative examples of the issue:

How is Shadow DOM different/better?

Shadow DOM provides native encapsulation for components' markup, behaviour and styles. This last part is what interests us the most here. Encapsulation of styles with shadow DOM is different from <style scoped> or CSS Modules in that it works both ways:

  • just like <style scoped> and CSS Modules it prevents styles from leaking outside of a component (scoping), but in addition
  • it prevents global styles from leaking into component (encapsualtion)

We will get later to the browser support, but shadow DOM is natively available in most of the modern browsers with some sad exceptions of course.

shadow DOM is capable of fixing the following Goals from original State of CSS document:

  • Infrastructure. shadow DOM with it's encapsulation allows to build truly independent components for as complex design systems as possible
  • Consistency. Again, is a component is encapsulated with shadow DOM, global styles do not affect its rendering (unless we explicitly allow for that. We'll see how to achieve this later) means we can be sure that such components will look consistently no matter the context they are used in
  • Maintainability/Quality. Since styles for a component within shadow DOM are encapsulated we do not need to rely on classes or any other hooks in HTML. For simple components we can go as simple as something like:
a { color: Magenta; }
div:first-child { border: 1px solid Yellow }

and be sure that these will be applied only to elements within this component (with these values it would be insane otherwise).

  • Autonomy. Obviously, working in isolated manner on a component allows for safer experiments and easier roll-back without need to rely on UX
  • shadow DOM also partially solves Iteration Cost in that it allows for reusable styles that are easy for engineers to integrate with. Though it doesn't mean that Engineers won't need to write CSS. It just means that writing CSS for Engineers might become much easier.

At this point it's worth mentioning issues with shadow DOM.

  • If you compare the list above with the original list in State of CSS document you'll notice that one goal is missing here: Performance. Unfortunately, shadow DOM doesn't help CSS performance. It might even make things a bit worse on this field of we need to re-use a lot of CSS in a lot of components. Supposedly, this might be improved on webpack level and any discussion/ideas/help in this regards would be very much appreciated.
  • Another problem of shadow DOM is its strict nature. It affects two areas:
    • CSS. If you, for some reason, actually want/need to style that or another component from a global stylesheet, it won't be as easy as styling a Vue component. But if the issue with performance is a very not-welcomed guest, strict nature of shadow DOM is such by design and later in the proposal we will see how styling/theming a web component from a global stylesheet is possible in a predictable and controllable manner.
    • JS. DOM events attached to elements with something like el.addEventListener(), but moved to shadow DOM, are not fired at the moment (because the elements are not in main DOM by the time events are attached). Meaning we need to revisit how the tooltips, dropdowns, etc are initialized.

Ok, shadow DOM. How?

Great question. Let's get to the actual proposed technique of introducing shadow DOM in the the project. In essence, the main idea of the proposed solution boils down to two simple steps:

  • The simplest possible wrapper, being a web component, "swallows" content put between its opening and closing tags (Vue component, HAML template snippet or, really, anything).
  • We apply styles from within shadow DOM and those get applied purely to the content of the component

In its core, the proposal provides a base class (sorry, functional programmers) for building aforementioned wrappers by extending this base class and provide CSS for a particular component. It doesn't make a lot of sense to automate application of styles since all components need different styling (that's the purpose of this whole process, isn't it?). And here, it only make sense to take a look at the actual code. The steps required to enable web components wrappers:

  1. Install dependencies and write the base class. In the example, the base class is in app/assets/javascripts/frankenstein_component.js (because why not). There are also new dependencies introduced:
  • lit-element. In order to make the process as efficient as possible and minimise friction, heavy-lifting of web components' management is delegated to external library that turns out to be another base class that we base our base class on… 🙄 lit-html found in yarn.lock is part of lit-element.
  • remaining dependencies are there just to provide sass-loader in webpack
  1. Set up webpack and babel. This commit contains:
  • some new dependencies for webpack and babel
  • adjustments to configurations of both
  • a custom loader for webpack in order to properly transform supplied stylesheets: we don't want to write our styles right in component, but rather import from external .scss files in order to re-use all existing stylesheets' infrastructure.
  1. Write the first wrapper, basing it on our base frankenstein_component class. In this commit we create the simples possible wrapper, that wraps part of .haml template without applying any style really. The styles written in app/assets/javascripts/component_wrappers/project_row_wrapper/index.jsare there just to show an example of how to write CSS right in the wrapper's file should you need this (useful for very simple components with not much styling).

That's it. If you would checkout the branch at this last commit and inspect your page, you would see something like this in your DevTools Screenshot_2019-03-08_at_02.23.59

At the same time, since our .haml template is now in shadow DOM and we do not add any styling within this shadow DOM, barely anybody would be impressed with actual rendering of this page: Screenshot_2019-03-08_at_00.18.00

But this experiment proves that no global styling gets into this component now (even the links are visually re-set). Encapsulation achieved.

More realistic examples

  1. Developing the example above, we can add more or less proper styles to it. To demonstrate the way of working with existing stylesheets we simply imported the whole stylesheets/framework and stylesheets/pages/projects here to simplify the things. The result after these changes, even though being served from shadow DOM, looks identical to the original rendering: Screenshot_2019-03-08_at_02.23.22

  2. Next example shows how to wrap an existing Vue component with our custom web component's wrapper. We wrap complex MRWidgetHeader component, add external stylesheet that again imports the whole framework.scss + required merge_requests.scss. Plus, we extend this mix with some funky stying, including CSS animation to show that this is possible as well. The result: Screenshot_2019-03-08_at_00.59.57

Here, it's worth noting that buttons within the wrapped component turned funky magenta color as specified in the component's stylesheet. But this styling doesn't get out of the component, hence Resolve conflicts and Merge locally buttons below are still styled as defined in global stylesheet.

  1. Wrapping .haml templates or ready .vue components is not all. We can wrap the whole Vue applications!. Though in this case, we have to extend our Vue application with additional method (notifyListeners() that dispatches an event once the application is mounted). This is required in order to notify the wrapper when the Vue application is mounted and therefore is ready to be wrapped into shadow DOM. Before this moment, we allow the application to stay in the main DOM. Screenshot_2019-03-08_at_00.56.04

Here it's worth noting that the application we've wrapped with web component in this example already contained another wrapper for MRWidgetHeader (see those magenta buttons?). But styles of that component and styles of the wrapping application do not conflict and co-exist just fine. Again, without affecting anything outside of the application's wrapper, hence default styling on those awards buttons at the bottom.

Advanced usage

Identically looking components.

Let's assume we have components that might have differently business logic (hence they are different components in the first place) but still should look identical. In this case there is no need to create two different wrappers. Just use exactly the same wrapper for both components!

Extend component's template without touching the component

There might be a need to slightly extend component's template in some of its appearances but you would like to avoid changing the component itself for different reasons. Let's consider the following artificial example Screenshot_2019-03-08_at_01.10.28 Here, we have exactly the same <dummy-vue-component> Vue component in 3 presentations:

  <dummy-vue-component v-bind:subheader="sub"></dummy-vue-component>
  <greeting-wrapper>
    <dummy-vue-component v-bind:subheader="sub"></dummy-vue-component>
  </greeting-wrapper>
  <fancy-wrapper>
    <dummy-vue-component v-bind:subheader="sub">
      ← This one uses additional HTML markup →
    </dummy-vue-component>
  </fancy-wrapper>

The text in the bottom one is injected into the component with <slot> but it is managed on the Vue component's level by default. But in addition to that we add two images in the bottom most wrapper. We achieve this on the wrapper's level without touching the Vue component:

import { html } from 'lit-element';
import Frank from './frankenstein_component';
import styles from './styles.scss';

class FancyElement extends Frank {
  static get styles() {
    return [styles];
  }

  render() {
    return html`
      <img class="image-border image-border-left" src="https://about.gitlab.com/images/home/icons-pattern-left.svg">
      <img class="image-border image-border-right" src="https://about.gitlab.com/images/home/icons-pattern-right.svg">
    `
  }

}
// Register the element with the browser
customElements.define('fancy-wrapper', FancyElement);

html function comes from lit-element and simply takes HTML as a template literal.

Note When we inject our Vue component, it gets appended to our shadow DOM. Hence, if you extend components template on the wrapper's level, everything you put into html`` will precede your Vue (or HAML, or anything) component. Potentially, this behaviour can be changed to accept optional parameter on wrapper saying whether to append or prepend component to shadow DOM.

Theming components. ATTENTION! Incredibly powerful tool ATTENTION!

Having completely isolated and scoped components is our main goal to avoid CSS bleeding. It's by design that we should have the same component look the same wherever we put it. But sometimes such strict behaviour is not welcome and we need to provide some sort of flexibility in order to adjust component based on its environment from global stylesheet or, for example, differentiate the same component in different environments (prod vs. dev vs. staging for example).

For this use-case, there is absolutely no need to create several wrappers. Instead we can theme the same wrapper and control its look and feel from the global stylesheet. Or even, from another component if this wrapper is nested within another wrapper/component.

As it has been mentioned several times, shadow DOM is strict and doesn't allow any styles to cross the border of it. But there are some exceptions:

So to theme components we will rely exactly on CSS custom properties. In essence, our wrapper should expose styling API to the outer environment and then the environment decides how to present that or another component. Let's take a look at an example. We do not care about the wrapper itself, just styles for it.

styles.scss

button {
  background-color: var(--button-background-color, Blue);
  ...
}

The snippet above essentially says that if the global custom property --button-background-color is defined, apply its value to background-color. Otherwise, make it Blue. As simple as that. But here comes the power of custom properties: now we suddenly can change this property in the global stylesheet based on any criteria we want!

global.scss

body {
  --button-background-color: transparent;
}
#main {
  --button-background-color: Green;
}
footer {
  --button-background-color: Magenta;
}

Moreover, we can assign value to a custom property dynamically with JS like:

document.body.style.setProperty('--button-background-color', 'transparent');
document.getElementById('main').style.setProperty('--button-background-color', 'Green');
document.querySelector('footer').style.setProperty('--button-background-color', 'Magenta');

as a result of user's interaction or any other actions.

Browser support (as of time of writing) for main technologies, introduced by this proposal:

Implementation of both: Shadow DOM and Custom Elements in Edge is a matter of time as both are in development there. Though these have not yet been shipped with any version of Edge unfortunately.

But since IE11 and Edge are essential browsers to support for us, corresponding polyfils should be considered for these browsers:

Questions

  • Q: Is shadow DOM accessible for assistive technologies?
    A: Yes, it is.

  • Q: Do these components work properly with Vuex after being put in shadow DOM?
    A: Yes, they do. All connections to the store remain intact and components get properly updated in response to store changes

  • Q: Can we use it with exiting bootstrap-vue?
    A: Yes, we can. We import stylesheets, mixins, variables, etc. exactly as we used to. So no problem here (see screenshots above).

  • Q: Since this <frankenstein-component> is based on lit-element base class, can't we extend directly from lit-element?
    A: Yes, we can. And sometimes this would be a preferable way of doing things: for example, components that do not require Vue might be simply implemented this way. Also styles in such cases might be put directly into component. But if we do not write component's template in html`` for wrapper, we will have to copy/paste the wrapping and some other utility functions for every component. <frankenstein-component> abstracts those away allowing us to concentrate purely on styles for our components.

  • Q: Does it work with Vue DevTools?
    A: Yes it does. BUT… unfortunately, even though all components get registered properly and are available in Devtools, when one hovers over a component in DevTools, the appropriate component on the page doesn't get highlighted.

  • Q: What are the main problems and things to check with this proposal?
    A: There are few:

    • presumably the main issue to fix at the moment is attachment of DOM events to elements under shadow DOM
    • as aforementioned, performance is a big issue as well. Possible solutions might be available at the webpack level though
    • need to think through production story for these wrappers
    • even though, the proposed solution doesn't assume editing existing Vue components (and doesn't care about what components we feed it at all) we still need to work a lot with splitting current .scss environment into component-level chunks. But exactly the same would be required for any solution if we want reusable and maintainable components
    • need to test translations within wrappers
    • apparently, we might not need lit-element and can write the base class using plain web components without dependencies

So, what do you think about all this?

Edited by Denys Mishunov

Merge request reports