RFC: Light DOM Support

Summary

LWC currently enforces the use of Shadow DOM for every component. This proposal aims to provide a new option, a toggle, which instead lets a component attach its content as children in the Light DOM.

Basic example

When the Shadow DOM option is turned off for a component, then its content is not attached to its shadow-root, but to the Element itself. Here is an example, showing whenever Shadow DOM is on or off:

Shadow DOM

<app-container-blue-shadow>
  ▼ #shadow-root (open)
      <div>
        <b>Blue Shadow:</b>
        <span class="counter">...</span>
        <button type="button">Add one</button>
      </div>
</app-counter-blue-shadow>

Light DOM

<app-container-blue-light><div>
      <b>Blue Light:</b>
      <span class="counter">...</span>
      <button type="button">Add one</button>
    </div>
</app-counter-blue-light>

As a result, when the content of a component lives in its children, it can be accessed like any other content in the Document host, and thus behave like any other content (styling, APIs, accessibility, third party tooling...).

Shadow DOM provides a wonderful, native component encapsulation and composition model.

The two main reasons for using Shadow DOM are:

Native Component Encapsulation

The Shadow DOM encapsulation model provides a way for the component author to keep the component internals hidden, with no effect from the broader environment. This means that global styles have no effect on those internals and those internals cannot be queried from outside of the component. This provides a way for widget-type components to be more portable (eg: embedding into 3rd party apps) at the cost of some complexity that makes the creation of a component or an application harder.

Native Component Composition

The Shadow DOM gives custom element developers a way to allow consumers to compose some content that it should render. Think about native elements like <select>. This is done via <slot> elements that are automatically filled by the content from the light DOM. Without Shadow DOM there is no native component composition and this functionality must be provided by the framework.

A single component that needs to stand on its own with its own set of functionality is a good candidate for shadow DOM. Salesforce Lightning components are a good example of that. While one or more components as part of an application might not need Shadow DOM, as their intended use is much clearer and their markup less fragile.

Motivation

Consumer applications require DOM traversal and observability of an application’s anatomy from the document root. Without this, theming becomes hard while 3rd party applications do not run properly:

Furthermore, the goal of Shadow DOM is not to enclose the entire application in a single shadow-bound element. We want to build UIs which are comprised of multiple web components, not UIs which are a single, top-level web component. Web components shouldn’t be the fundamental mechanism for building everything in an app, otherwise they negate the usefulness of standard semantic HTML. This is why we think the current model is fundamentally flawed. shadow spectrum

Prior Art

Most of the libraries designed to support Shadow DOM also propose a Light DOM option, with a variety of Shadow DOM features (slots, scoped styles, etc.). It includes:

Or frameworks that are built for Light DOM offer a Shadow DOM option:

Detailed design

Selecting Light DOM vs Shadow DOM

The selection of Light DOM vs Shadow DOM is under the control of the component developer. It is done at the component class level, through a class level directive. Ideally, this directive does pollute the class namespace and thus uses a symbol. It cannot be changed dynamically or by the execution context. The LWC compiler can also use this information to check any issue and warn the developer.

import { LightningElement, ShadowDom } from 'lwc';

export default class MyComponent extends LightningElement {

    static [ShadowDom] = false;
}

Component features when using Light DOM

Some of the LWC component capabilities are directly inherited from Shadow DOM, or emulated by the synthetic-shadow. Despite the use of Light DOM, we’d like to keep these features available, even if their behavior is slightly adapted to the Light DOM:

Also, Light DOM carries some specific behaviors:

$Refs (WIP)

In order to make element querying in the Light DOM less error prone (slots, descendants toggling shadow), we can consider implementing a $ref mechanism similar to React or Vue.

Security (WIP)

Component Migration

There is no migration of the existing components needed. The behaviors of existing components using Shadow DOM remain the same.

Internal Implementation

Fortunately, most of the code already exists in the LWC core runtime, as it has been implemented to support synthetic shadow. This makes the implementation much easier, and only touching a few code blocks.

Render Root Each LWC component has a VM (View Model) associated to it which carries the component runtime information. The VM class is extended with a new attribute defining if the Shadow DOM is being used:

export interface VM<N = HostNode, E = HostElement> {
    
    shadowDom: boolean;
}

This attribute is set when the VM is created, which is very early in the component creation. For this, it introspects the component class and reads its ShadowDom static member. It defaults to true, which means Shadow DOM enabled, if the member does not exist. If this attribute is not defined at the component class, it eventually uses the one from the parent class, thanks to the chaining prototypes.

Similarly, the VM.cmpRoot is either the component shadow root, created calling attachShadow, or the element itself:

    const cmpRoot = vm.shadowDom ? renderer.attachShadow(elm, ...) : elm
    vm.cmpRoot = cmpRoot;

Scoped Styles The synthetic-shadow implementation is reused, where the runtime now checks for either shadowDom and syntheticShadow where it used to only checks for syntheticShadow. For example:

function createStylesheet(vm, stylesheets) {
  const { shadowDom, renderer } = vm;
  if (!shadowDom || renderer.syntheticShadow) {
    // some code

The DOM engine implementation should be extended to add the scoped styles to the closest DocumentFragment (ShadowRoot container) in the hierarchy if any. If none, then the styles are added to the document root as it does right now.

Server Side Rendering The engine-server module providing the SSR capability can seamlessly render Shadow DOM or Light DOM. It includes the component children, as well as the scoped styles.

Synthetic Shadow DOM The selection of Light DOM should not be impacted by the use of synthetic shadow instead of native shadow. Now, the goal is to get rid of synthetic shadow, but this is hardly possible today because of:

Could we think of a lightweight synthetic shadow that do not override the global methods but provides enough functions to the base components to work while letting third party integration tools work? This can be a different DOM option, which is an hybrid between Shadow DOM and Light DOM.

POC

More implementation details available through this POC:

Potential issues

Slot Propagation Slot propagation is not available between components using Shadow and Light DOM intermingled. Typically, the use case below will not work:

<!-- App.html, shadow on/off, doesn't matter -->
<template>
  <Container>
    <div>Slotted content!</div>
  </Container>
</template>

<!-- Container.html, shadow on -->
<template>
  <Comp>
    <slot></slot>
  <Comp>
</template>

<!-- Comp.html, shadow off -->
<template>
  <slot>default content</slot>
</template>

This is an edge case that a developer could fix by ensuring that the Comp component is actually using Shadow DOM.

Drawbacks

Design Alternatives

Light DOM Selection

There are alternate ways to instruct the LWC compiler/runtime to use Light DOM.

As a component method

LitElement enables Light DOM via a component method. This method returns the render root, which can be the shadow-root, the current component or anything else:

class LightDom extends LitElement {
  createRenderRoot() {
    return this;
  }
}

This is very flexible and allows the creation of portals. But there are more security implications that are out of the of the scope of this RFC. Moreover, it doesn’t tell explicitly if Shadow DOM is in use or not, so this will make the implementation harder (scoped styles, slots, ...), while preventing a static analysis of the code.

At the application level

The compiler can use a list of namespaces, defining the list of components that should use Light DOM.

The main drawback of this approach is that the switch is not controlled by the component developer. Even if we try to keep the API behaviors similar when using Light DOM or Shadow DOM, some components will only work in one mode or the other. For example, the design of the HTML template using CSS styles might require Light DOM. So we believe that the use of Light DOM should be under the control of the developer. On the other hand, we can prevent components using Light DOM to be created contextually: the UI builder doesn’t show them if they are not enabled, and the runtime prevents their instantiation if the context does not allow them.

At the component instance level

The component tag can feature a new directive, like lwc:shadow=“off”, to instruct the compiler to use the Light DOM for a component instance.

Adoption strategy

This new feature does not break any existing components, it simply adds a new feature that developers have to opt for. There is no migration of the existing components needed.

This feature should be exposed and explained to the component library developers as they might change how they develop their components internally.

How we teach this

Shadow DOM and Light DOM are already names accepted by the industry, see: Terminology: light DOM vs. shadow DOM. We need to provide the proper documentation to educate the LWC developers:

Unresolved questions

How much do we want to make the use of Light DOM transparent and similar to Shadow DOM? As the selection of Light DOM is controlled by the developer, it feels ok to have some slight differences. Not only it makes the implementation easier and better performing, but the developer can fully take advantage of the Light DOM when it makes sense. We introduce the Light DOM to expand the power of LWC, not to provide a different implementation of the synthetic-shadow. Here are some differences:

undefined