Our experience upgrading web components from Shadow DOM/Custom Elements v0 to v1
October 17, 2016
With Google now shipping both Shadow DOM v1 and Custom Elements v1 in Chrome, and Apple shipping Shadow DOM v1 in Safari, we’ve been upgrading the Basic Web Components library from the original v0 specs to v1. Here’s what we learned, in case you’re facing a similar upgrade of your own components, or just want to understand some ramifications of the v1 changes.
Upgrading components to Shadow DOM v1: Easy!
Google developer Hayato Ito has a great summary of
What’s New in Shadow DOM v1.
Adapting our components to accommodate most of the changes on that list was
trivial, often just a matter of Find and Replace. The v0 features that were
dropped were ones we had never used (multiple shadow roots, shadow-piercing
CSS combinators) or had avoided (<content select=”...”>
), so
their absence in v1 did not present a problem.
One v1 feature that we had heavily lobbied for was the addition of the slotchange event. The ability of an element to detect changes in its own distributed content is a critical addition to the spec. We are happy to replace our old, hacky method of detecting content changes with the new, official slotchange event. This allows us to easily write components that meet the Content Changes requirement on the Gold Standard checklist for web components.
Upgrading components to Custom Elements v1: Some challenges
The changes from Custom Elements v0 to v1 were more challenging, although some were easy:
-
Replacing
document.registerElement()
withcustomElements.define()
. No issues. -
Drop support for
is=""
syntax. Ever since Apple announced that they would not support the syntax, we’ve avoided it. As a workaround, a while back we created a general wrapper component called WrappedStandardElement. - Tweaks in lifecycle callback timing. There were spirited spec debates over the exact points in time when a component’s lifecycle callbacks should be invoked, but we didn’t notice any practical differences between v0 and v1.
-
attributeChangedCallback
automatically invoked at constructor time. This was a welcome change that allowed us to simplify our AttributeMarshalling mixin, which automatically translates attribute changes into property updates.
One small obstacle we hit is that a v1 component now needs to declare which
attributes it wants to monitor for changes. This performance optimization in
Custom Elements v1 requires that your component declare an
observedAttributes
array to avoid getting
attributeChangedCallback
invocations for attributes you don’t
care about. That sounds simple, but in our mixin-based approach to writing
components, it was actually a bit of a pain. Each mixin had to not only
declare the attributes it cared about, but it had to cooperatively construct
the final observedAttributes
array. We eventually hit on the idea
of having the aforementioned AttributeMarshalling mixin programmatically
inspect the component class for all custom properties, and automatically
generate an appropriate array of attributes for
observedAttributes
. That seems to be working fine.
A more problematic change in v1 is that component initialization is now done
in a class constructor instead of a createdCallback
. The change
itself is a desirable one, but we expected it would be tricky, and it was. The
biggest problem we’ve encountered is that the list of
Requirements for custom element constructors
prohibits a new component from setting attributes in its constructor. The
intention, as we understand it, is to mirror standard element behavior.
Calling createElement('div')
returns a clean div with no
attributes, so calling createElement('my-custom-element')
should
return a clean element too, right?
That sounds good but turns out to be limiting. Custom elements can’t do everything that native elements can, and sometimes the only way to achieve a desired result is for a custom element to add an attibute to itself:
-
A component wants to define default ARIA attributes for accessibility
purposes. For example, our
ListBox
component needs to add
role=”listbox”
to itself. That helps a screen reader interpret the component correctly, without the person using the component having to know about or understand ARIA. Thatrole
attribute is a critical part of a ListBox element, and needs to be there by default. -
A component wants to reflect its state as CSS classes so that component
users can provide state-dependent styling. For example, our
CollapsiblePanel
component wants to let designers style its open and closed appearances by
adding CSS classes that reflect the open/closed state. This component
reflects the current state of its
closed
property via CSS classes. It’s reasonable that a component would want to set the initial state of thatclosed
property in a constructor. But setting that default value of that property in the constructor will trigger the update to the CSS class, which is not permitted in Custom Elements v1.
In these cases, it doesn’t seem like it would be hard to just set the
attributes in the connectedCallback instead. In practice, it introduces
complications because a web app author that instantiates a component would
like to be able to immediately make changes to it before adding it to the
document. In the first scenario above, the author might want to adjust the
role
attribute:
class ListBox extends HTMLElement { connectedCallback() { this.setAttribute('role', 'listbox'); } } let listBox = document.createElement('basic-list-box'); listBox.setAttribute('role', 'tabs'); // Set custom role document.body.appendChild(listBox); // connectedCallback will overwrite role!
Because ListBox can’t apply a default role
attribute at
constructor time, its connectedCallback will have to take care to see if a
role
has already been set on the component before applying a
default value of role=”listbox”
. It’s easy for a developer to
forget such a check. The result will likely be components that belatedly apply
default attributes, stomping on top of attributes that were applied after the
constructor and before the component is added to the document.
Another problem comes up in the second scenario above. The creator of the component would like to be able to write a property getter/setter that reflects its state as CSS classes:
let closedSymbol = Symbol('closed'); class CollapsiblePanel extends HTMLElement { constructor() { // Set defaults this.closed = true; // Sets the “class” attribute, so will throw! } get closed() { return this[closedSymbol]; } set closed(value) { this[closedSymbol] = value; this.toggleClass('closed', value); this.toggleClass('opened', !value); } }
Since the above code won’t work, the developer has to take care to defer all
attribute writes (including manipulations of the classList, which updates the
class
attribute) to the connectedCallback
. To make
that tolerable, we ended up creating
safeAttributes, a set of helper functions that can defer premature calls to
setAttribute()
and toggleClass()
to the
connectedCallback
.
That’s working for now, but it feels like the v1 restrictions on the
constructor are overly limiting. The intention is to ensure that the component
user gets a clean element from createElement()
— but if the
resulting element is just going to add attributes to itself in the
connectedCallback
, is that element really clean? As soon as the
attribute-less element is added to the document, it will suddenly grow new
attributes. In our opinion, that feels even more surprising than having
createElement()
return an element with default attributes.
The current state of Shadow DOM and Custom Elements v1
Overall, we’re excited that we’ve got our components and mixins working in production Chrome 54, which just shipped last week with support for both Shadow DOM v1 and Custom Elements v1. The Chrome implementation of the specs feels solid, and we haven’t hit any bugs.
Shadow DOM v1 is also coming together in Safari, including in Mobile Safari. At the moment, it feels more like a beta than a production feature — we’ve hit a number of critical bugs in WebKit that prevent most of our components from working. Apple’s working through those bugs, and we hope to see WebKit’s support for Shadow DOM improve soon.
In the meantime, Google has been doing the thankless, herculean task of upgrading the Shadow DOM and Custom Elements polyfills to the v1 specs. That’s great to see, because without an answer for older browsers, web components won’t see wide adoption. At the moment, the v1 polyfills also feel like a beta, but they’re coming along quickly. As soon as the polyfills are stable enough, we’re looking forward to making a full release of Basic Web Components based on the v1 specs.