DEV Community

Cover image for Turning up to Lit
Westbrook Johnson
Westbrook Johnson

Posted on • Edited on

Turning up to Lit

This a cross-post of a Feb 9, 2019 article from Medium that takes advantage of my recent decision to use Grammarly in my writing (so, small edits have been made here and there), thanks for looking again if you saw it there 🙇🏽‍♂️ and welcome if this is your first time.

There’s a deep desire in me to start by joking that the following diff is all you need to make the upgrade from Polymer 2.0 to LitElement happen:

- "polymer": "Polymer/polymer#^2.0.0",
+ "lit-element": "^2.0.0",
Enter fullscreen mode Exit fullscreen mode

Not only would that make my (and your) work so much easier and the euphemism of simply turning up the volume so much more applicable, but that would be one hell of a mic drop, amirite? Alas, the road our hero must tread is a little longer than that, however, you’ll all be glad to hear that very few magic spells or superpowers, if any, will be required in order to make the trip.


(Editor’s note: This article positions itself as explicitly supporting the upgrade from Polymer 2 to LitElement. However, it is important to realize the functional differences between Polymer 2 and Polymer 3 are few and far between. If you’ve already worked through this transition then feel free to skip past the Dependency Management and Dependency Usage sections below and get right into updating the implementation of your custom element from Polymer to LitElement.)


Before we get started, let’s talk a little bit about where the code for our Polymer 2.0 element we’ll be upgrading comes.

Opinionated Element

GitHub logo Westbrook / generator-polymer-init-opinionated-element

Polymer 2.0 component with some convinence Yarn scripts and file structure decisions for your developing pleasure.

generator-polymer-init-opinionated-element

Polymer 2.0 component with some convinence Yarn scripts and file structure decisions for your developing pleasure.

Installation

First, install Yeoman and generator-polymer-init-opinionated-element using npm (we assume you have pre-installed node.js).

yarn global app polymer-cli
yarn global add yo
yarn global add generator-polymer-init-opinionated-element
Enter fullscreen mode Exit fullscreen mode

Then generate your new project:

polymer init
? Which starter template would you like to use?
❯ opinionated-element - Polymer 2.0 component generator with some convinence Yarn scripts and file structure decisions for your developing pleasure.
? What is your name? (You Name)
? What is your GitHub username or organization? (recent-org)
? What is the namespace that you would like to build this component into? (org)
? What would you like this element to be called? (name-of-element) //notice that web components have to have a hyphen in the name
 What does this element do? (nothing yet, but something awesome)  
Enter fullscreen mode Exit fullscreen mode

Over the years I’ve used a number of different techniques to create Polymer-based web components, but most recently settled on the approach applied in generator-polymer-init-opinionated-element. The project is published to NPM, so if you want to take a test drive of what is provided to you there so that you understand the baseline side of this upgrade, be my guest. The most important parts of its generating process in regards to our upgrade are as follows:

  1. the use of a custom-element.html/custom-element.js/custom-element-styles.html file structure as opposed to a single file component that had all of this code delivered via a single custom-element.html
  2. BrowserSync is applied for serving the code during development
  3. CircleCI and Release It are applied to the code maintenance lifecycle
  4. Web Component Tester is used for x-browser testing with Sauce Labs
  5. A bunch of helper NPM scripts supports installing Bower locally rather than globally.

While code maintenance lifecycle and testing are important to any project, the first point here is probably the most striking. Many found the single file component approach of Polymer to be one of it’s most positive features and it certainly was one of my favorite in my early interactions with the library. Thankfully, an insightful colleague fought hard for not using it when we first started working together as it both made debugging in polyfilled browsers easier (the JS files were actually listed as JS files in Safari/Firefox/Edge when you load them this way) and set up a much simpler transition to techniques applied in the new web component generator I’ve been working on.

GitHub logo Westbrook / generator-easy-wc

File structure and Yarn scripting for developing, testing, documenting, and deploying a web component.

generator-easy-wc NPM version Build Status Dependency Status

File structure and Yarn scripting for developing, testing, documenting, and deploying a web component.

Installation

First, install Yeoman and generator-easy-wc using yarn (we assume you have pre-installed node.js).

yarn global add yo
yarn global add generator-easy-wc
Enter fullscreen mode Exit fullscreen mode

Initialization

Then generate your new project. Creating the git project before generating allows husky to set up your hooks appropriately. Once you've committed the generated code to master, I'd suggest branching immediately for appropriate use of a PR after you've established the functionality of your element:

mkdir element-name-here
cd element-name-here
git init
yo easy-wc
# ... follow the prompts ...
git add .
git commit -am 'Generated Element'
git checkout -b element-name-here
Enter fullscreen mode Exit fullscreen mode

Prompts

    _-----_     ╭──────────────────────────╮
   |       |    │      Welcome to the      │
   |--(o)--|    │      super-excellent     │
  `---------´   │     generator-easy-wc    │
   ( _´U`_ )    │        generator!
   /___A___\   /╰──────────────────────────╯
    |  ~  |     
  __'
Enter fullscreen mode Exit fullscreen mode

If you’re thinking you want to skip what might be an annoying upgrade process and go straight to making new elements from scratch, I’d suggest that, rather than starting there, you check out the great work coming out of the team at Open Web Components.

If you’re ready to start making the upgrade, let’s dive in!

Disclaimer

If you’ve done you’re homework and taken an in-depth look at generator-polymer-init-opinionated-element you might catch some simplification in the following changes. However, I’ll do my best not to overlook anything that would be explicitly part of the upgrade process.

Starting with the Simple Changes

One of my favorite parts of refactoring, in general, is getting to delete things, and most of the simple changes we’ll be making are just that, deletions!

// .circleci/config.yml

-      - run: yarn install:bower
Enter fullscreen mode Exit fullscreen mode

No more bower install in the CI.

// config/.release-it.json

-    "package.json",
-    "bower.json"
+    "package.json"
Enter fullscreen mode Exit fullscreen mode

No more version management in bower.json.

// package.json

-    "install:bower": "bower install",
-    "install:bower:clean": "rimraf bower_components && bower install",
-    "sync": "browser-sync . -w -c 'config/bs-config.js'",
-    "element:clean": "rimraf bower_components/ll-course-listing",
-    "element:directory": "mkdir bower_components/ll-course-listing",
-    "element:copy-files": "yarn copy bower_components/ll-course-listing",
-    "element:make": "yarn element:directory && yarn element:copy-files",
-    "prelive": "yarn analyze && yarn element:clean && yarn element:make",
-    "live": "yarn sync",
-    "copy": "cp -r *.html *.js analysis.json demo test",

// ...

-    "bower": "^1.8.2",
Enter fullscreen mode Exit fullscreen mode

No more bower related dependencies or scripting. The upgrade will also include full removal of bower.json and custom-element.html, however, keeping them around a little longer to support some less simple upgrade steps in a good idea.

Here you will also notice that we’ve removed scripting to control BrowserSync. I noticed continually diminishing returns while using it in the past, and while that may certainly be related to my usage and not the capabilities of BrowserSync itself, I’ve been most happy with this removal. It’s absence in your scripts also means you can run rm config/bs-config.js against your project folder in the command line to clean up the BrowserSync configuration file that will no longer be needed.

There are also a couple of simple additions that should be taken:

// wct.conf.json

      "sauce": {
        // ...
        "browsers": [
+        {
+          "browserName": "firefox",
+          "platform": "Windows 10",
+          "version": "60"
+        },

// ...

           "browserName": "safari",
-          "platform": "OS X 10.12",
+          "platform": "OS X 10.13",
Enter fullscreen mode Exit fullscreen mode

I’d never found a good way to upgrade the Firefox that was available locally to the CircleCI virtual machines, so Firefox had been left out of previous x-browser testing, but this adds it back via SauceLabs’ remote testing tools with particular attention paid to one of the last versions not to feature Custom Elements and Shadow DOM APIs. The macOS platform version bump is needed to avoid an issue in early Safari 11 that disallowed the use of async as an import/export key.

A cogent argument could certainly be made to point this testing to newer versions or broader numbers of browsers, so please feel free to continue to add what you feel is best for your project to this very minimal baseline.

// polymer.json

{
+  "entrypoint": "index.html",
+  "shell": "custom-element.js",
+  "fragments": [
+    "*.js"
+  ],
+  "lint": {
-    "rules": ["polymer-2"]
-  }
+    "rules": ["polymer-3"]
+  },
+  "sources": [
+    "node_modules/@polymer/iron-demo-helpers/**/*.js",
+    "node_modules/@webcomponents/**/*.js",
+    "node_modules/wct-browser-legacy/**/*.js",
+    "index.html",
+    "demo/*"
+  ],
+  "moduleResolution": "node",
+  "npm": true
}
Enter fullscreen mode Exit fullscreen mode

The most important additions here are the moduleResolution and npm properties. moduleResolution: 'node' will allow you to import dependencies via node style bare module specifiers (i.e. 'lit-element/lit-element.js'), by rewriting the URLs on the fly, you can learn more about this via the Polymer Project’s blog. npm: true, outlines to the Polymer CLI how it should acquire the package name and location of dependencies. The rest of the changes support the way the polymer serve and polymer build decide which files to read/copy when doing their work. Please note that if you work with third party code in your demos/tests that might not be directly depended on in your elements you will need to list those files in the sources entry.

Dependency Management

One of the biggest leaps from Polymer 2.0 to LitElement is the change from HTML Imports as supported by the Bower package management ecosystem to ES Modules as supported by NPM. In short, the bad news is that neither NPM nor yarn is fully prepared to manage the flat dependency tree required by web components the way Bower is, but the good news is now it’ll be easier than ever to import packages from the JS community at large into your projects. While there is much that could have been added to your bower.json overtime, the most important part it will play in this upgrade is outlining the dependencies that need to be moved to package.json:

// bower.json

- "dependencies": {
-    "polymer": "Polymer/polymer#^2.0.0",
-    // as well as any other dependencies you might have been using
-  },
-  "devDependencies": {
-    "iron-demo-helpers": "PolymerElements/iron-demo-helpers#^2.0.0",
-    "web-component-tester": "Polymer/web-component-tester#^6.0.0",
-    "webcomponentsjs": "webcomponents/webcomponentsjs#^1.0.0",
-    "iron-component-page": "polymerelements/iron-component-page#^3.0.1"
-  },
Enter fullscreen mode Exit fullscreen mode

The most complicated piece of this removal is the “any other dependencies” bit. That is, were you depending on any sort of 3rd party code you’d need to ensure that it is ES Module compliant (or convertible, even if disgusting) to enable you to make this upgrade. Luckily the majority of the dependencies I work with are internal, hopefully, you can be lucky in either this way or to be depending on up-to-date projects so that you can make a direct addition to package.json:

// package.json

+  "dependencies": {
+    // ...any other dependencies you might have been using
+    "@webcomponents/webcomponentsjs": "latest",
+    "lit-element": "^2.0.0"
+  },
+  "devDependencies": {
+   "@polymer/iron-component-page": "^4.0.0",
+   "@polymer/iron-demo-helpers": "^3.0.0",
+   "@polymer/test-fixture": "^4.0.0"

// ...

-    "wct-istanbub": "^0.0.7",
-    "web-component-tester": "^6.4.1"
+    "wct-istanbub": "^0.2.1",
+    "wct-mocha": "^1.0.0"
+  }
Enter fullscreen mode Exit fullscreen mode

For ease of conversion, when you have the option to directly upgrade a dependency from the Bower version to the NPM version, I’d highly suggest that you take that as a good omen and run with it at this point. Once your entire element has been upgraded to ES Modules, then you can start targeting various dependencies for localized upgrades to newer versions, faster versions, more purpose-built versions, etc. One of the benefits of this transition is direct access to a much larger part of the JS community and the tools they have been developing, so hopefully, there is lots of possibility opening up to your elements via this work.

Dependency Usage

The move from HTML Imports to ES Modules being a central part of the upgrade from Polymer 2, this is where a good amount of the manual work for this conversion will be. For every external dependency, you’ll be converting those imports, like:

<link rel="import" href="../dependency/dependency.html">
Enter fullscreen mode Exit fullscreen mode

To:

import {dependency} from dependency/dependency.js';
Enter fullscreen mode Exit fullscreen mode

Some of these things will be pretty straight forward (i.e. child component assets that define and register themselves), some will be slightly less (i.e. dependencies that had previously injected themselves into the Global scope that will now need to be altered for local usage in a scopes ES Module) and some will involve deeper, more pervasive changes across your component. It would be pretty difficult to cover all of the conversion possibilities herein, so please feel free your stories of heroic refactoring in the comments below!

Base Class

The following made the Polymer.Element base class available for extension:

<link rel="import" href="../polymer/polymer-element.html">
Enter fullscreen mode Exit fullscreen mode

Will be replaced with a similar import in JS space that makes the LitElement base class available for extension:

import {LitElement, html} from 'lit-element/lit-element.js';
Enter fullscreen mode Exit fullscreen mode

This will allow for the class declaration to be updated from:

class CustomElement extends Polymer.Element {
Enter fullscreen mode Exit fullscreen mode

To the following:

export class CustomElement extends LitElement {
Enter fullscreen mode Exit fullscreen mode

Exporting the class declaration makes it easier to extend our components and leverage advanced techniques (like registering the custom element in an external JS file or even on-demand, over and over again) available as needed.

Styles

Previously, styles had been formed into a dom-module for including in our element via the <style include="custom-element-styles"></style> syntax.

<dom-module id="<%= elementName %>-styles">
  <template>
    <style>
      :host {
        display: block;
        box-sizing: border-box;
      }
    </style>
  </template>
</dom-module>
Enter fullscreen mode Exit fullscreen mode

HTML Imports were relied on to make those styles available in our elements:

<link rel="import" href="custom-element-styles.html">
Enter fullscreen mode Exit fullscreen mode

Here, our move to ES Modules has this update looking much like the work we did to make the LitElement base class available:

import {style} from './custom-element-styles.js';
Enter fullscreen mode Exit fullscreen mode

This code can now be applied via Constructible Stylesheets when available meaning that rather than each instance of a custom element having its own <style/> tag, all of those instances can share a single one via element.adoptedStyleSheets = [...]. To make this possible LitElement offers a css tag for use in static get styles which leverages those capabilities while providing a suitable fallback in browsers without support for this feature. That means our stand-alone styles file can now look more like:

import {css} from 'lit-element/lit-element.js';
export const style = css`
  :host {
    display: block;
    box-sizing: border-box;
    contain: content;
  }
:host([hidden]) {
    display: none;
  }
`;
Enter fullscreen mode Exit fullscreen mode

And it can be applied in your elements, a la:

static get styles() {
  return [style];
}
Enter fullscreen mode Exit fullscreen mode

The returned array allows for the composition of multiple style declarations into this single element, which allows for more easy sharing of styles across multiple elements. You can learn more about this technique at the LitElement documentation site.

Attributes and Properties

Once you’ve got your external dependencies worked out, one of the most important element-internal concepts you’ll want to update is your element's attributes and properties. Much like Polymer 2, LitElement relies on static get properties() to allow our custom element to register these properties as observedAttribues which empowers attributeChangedCallback to respond as needed to changes to those attributes. A property described in this way might look like the following in Polymer 2:

static get properties() {
  return {
    everythingProperty: {
      type: String
      value: 'default value',
      reflectToAttribute: true,
      notify: true,
      computed: 'computeEverythingProperty(firstWord, secondWord)',
      observer: 'observeEverythingProperty'
    },
    firstWord: {
      type: String,
      value: 'default'
    },
    secondWord: {
      type: String,
      value: 'value'
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Here we’re outlining the everythingProperty is String with a default value of “default value”. This value will be reflected to an attribute everything-property, as Polymer coverts camel case to kabob case internally when working with attributes, and will notify it’s parent of changes to its value. Further, the value of everythingProperty will be computed from the values of firstWord and secondWord, and when that causes the value of the property to change the observeEverythingProperty method will be called to handle that change. All the while, because of its having been registered this way, we can rely on changes to everythingProperty to also tick off the render pipeline of our custom element. When updating to LitElement, we will continue to use static get properties(), but we should do so with the knowledge that the settings available in LitElement provide a richer and more highly customizable set of options for managing this attribute meets property meets render pipeline relationship.

Type

When moving from Polymer 2 to LitElement, the inner workings of the type entry of your properties getter will feel fairly familiar. Much of the work that was being done for you in Polymer 2 is available via LitElement, which allows for the continued use of types like String, Number, Boolean, Array, and Object while being able to rely on your base class to take care of the conversion between attributes (which are always strings) and the richer data structures you would expect from your properties. Beyond this support, LitElement has a converter entry in its property descriptor where you can customize the processing used to convert an attribute to a property and back again, as well as an attribute entry in the case that you want to listen to changes on an attribute with a name other than the property that you use internal of your component to manage this data. The attribute entry serves our upgrade in that is allows us to have direct control as to how everythingProperty is associated with an attribute (everythingproperty by default). Notice the lack of uppercasing in the attribute that is listened to by default, this is due to realities around the HTML parser. Camel case in your HTML may work fine in Chrome but the parse in FireFox and Edge will give you trouble, which is why Polymer translated this to kabob case (everything-property) by default. You now have the power to make your own decisions here. Both of these new entries greatly extend the world of possibilities around handing attribute supplied data in your element's properties.

Value

In LitElement the ability to set a default value has been removed in favor of setting those defaults in the constructor(). This can be a bit of a surprise when moving from Polymer 2 where the default was set via the value entry, so keep an eye out for this in your code. This change can be seen as preparing your code for the use of Public Class Fields (already available in Chrome) in the near future or the use of ES7 Decorators a little further down the road. (Note: Decorators are currently available in LitElement via TypeScript.)

reflectToAttribute

When set to true the reflectToAttribute entry would ensure that changes to your properties would be reflected in the related attribute of our element in the DOM. This functionality persists in LitElement via the reflect entry that will pair with your type or converter definition to manage the application of your property back to its corresponding attribute.

notify

The ability to automatically notify when properties have changed in your element, a key part of the Polymer 2 two-way binding technique, has been removed by default in LitElement. There are some external projects that look to mix this functionality back into your elements, however, in this article, we will visit replacing this functionality manually in the “Template” section below.

computed

The computed entry has been completely removed in LitElement. There are a number of different ways that you can manage the transition to this. Which is the best for your use case relies on a number of different factors:


This means that in the context of our example above, which roughly amounts to:

computed: 'computeEverythingProperty(firstWord, secondWord)',

// ...

computeEverythingProperty(firstWord, secondWord) {
  return `${firstWord} ${secondWord}`;
}
Enter fullscreen mode Exit fullscreen mode

We’d be well within the capabilities and needs of our component to simply turn this into a getter like the following, and call it a day.

get everythingProperty() {
  return `${this.firstWord} ${this.second}`;
}
Enter fullscreen mode Exit fullscreen mode

However, as the complexity of our computation grows, the likelihood that other techniques would be more favorable does as well. In order to move this computation from every render() to only when the originating properties are changing you could rely on the updated() lifecycle method, a la:

updated(changedProperties) {
  if(
    changedProperties.has('firstWord') ||
    changedProperties.has('lastWord')
  ) {
    this.everythingProperty = `${this.firstWord} ${this.lastWord}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

There is also the option to move this gating into the template by relying on the guard directive as supplied by lit-html, which might add further benefits around reuse/composability:

import {guard} from 'lit-html/directives/guard';

// ...

${
  guard(
    [this.firstName, this.lastName],
    () => html`${this.firstName} ${this.lastName}`
  )
}
Enter fullscreen mode Exit fullscreen mode

You could even move beyond these techniques to apply some form of memoization via your own library code or third party tools as you see fit.

observer

The observer entry has also been omitted by LitElement. Much like the above code for supporting computed properties, the updated() lifecycle method allows for the manual replacement of this functionality:

updated(changedProperties) {
  if (changedProperties.has('everythingProperty')) {
    // observe change
  }
}
Enter fullscreen mode Exit fullscreen mode

A similar effect can be achieved via a custom getter/setter for the property, where you will have expanded control over the way these new properties enter the render pipeline:

get everythingProperty() {
  return this._everythingProperty;
}
set everythingProperty(value) {
  // observe this "change" every time the property is set
  if (value === this._everythingProperty) return;
  let oldValue = this._everythingProperty;
  this._everythingProperty = value;
  // observe change before the render.
  this.requestUpdate('everythingProperty', oldValue);
  // observe this change after requesting a render
  this.updateComplete.then(() => {
    // observe this change after the render completes
  });
}
Enter fullscreen mode Exit fullscreen mode

Properties Overview

There is much about the static get properties() interface that will feel the same in LitElement as it did in Polymer 2, so it is important to take heed of the things that are actually the same and those things that really are different. In general, where automatic capabilities that you might have come used to relying on Polymer 2 have been removed, the benefits of those conversion costs are the possibility of deeper integration into your elements lifecycle when implementing those features manually, without being locked to exactly and only those features. As you find extended patterns that you feel are important in empowering the types of applications you build, don’t forget that these things can be packaged into extended base classes that can be shared across your own projects, those of the teams you work with, or shared across the entire community via NPM, GitHub, or Polymer Slack, now that our upgrade from Polymer 2 has removed Bower and HTML Imports from our toolchains.

Your Template

Early Polymer 2 based elements relied on the <dom-module/> approach to applying a template to an element:

<dom-module id="custom-element">
  <template>
    <style include="custom-element-styles"></style>
    <slot></slot>
    <h1>Hello [[prop1]]<h1>
    <input value="{{prop2::input}}" />
  </template>
  <script src="custom-element.js"></script>
</dom-module>
Enter fullscreen mode Exit fullscreen mode

This single <template/> child of the <dom-module id='custom-element'/> was used with scoped data binding to describe the shadow DOM for your element. Further, we see the [[prop1]] property is bound to the content of the <h1/> and the {{prop2}} property being two-way bound to the value of the input element based on its input event. LitElement doesn’t allow HTML based templating (by default) and omits support for two-way binding in favor of a data flowing in a single direction, so there will be much that needs changing when it comes to the inner workings of this template.

Later versions of Polymer 2 supported a custom Polymer.html template tag that would have already positioned your template at an intermediary step along this upgrade path. In the case that (like generator-polymer-init-opinionated-element) you were already using the Polymer.html tag and associated static get template() method, the above would look more like (or could be converted to):

static get template() {
  const html = Polymer.html;
  return html`
      <style include="custom-element-styles"></style>
      <slot></slot>
      <h1>Hello [[prop1]]<h1>
      <input value="{{prop2::input}}" />
  `;
}
Enter fullscreen mode Exit fullscreen mode

Which requires much less conversion when moving to LitElement. Remember we’re importing an html template tag from lit-element/lit-element.js so a straight refactoring would look like:

render() {
  return html`
      <slot></slot>
      <h1>Hello ${this.prop1}<h1>
      <input .value="${this.prop2}" @input="${this.handleInput}" />
  `;
}
handleInput(e) {
  this.prop2 = e.target.value;
}
Enter fullscreen mode Exit fullscreen mode

Remember that our styles are now being applied via static get styles and no longer need to be included in the template. Notice that the value of the input is being bound as a property (.value="${this.prop2}"), this allows for the visible value of the input to follow the value kept by prop2. The event binding of @input replaces the two-way binding that had been previously attained by the double curly brace syntax + event name ({{prop2::input}}) syntax with more explicit event handling in the code of your custom element.

Two-way Binding

When addressing the upgrade from Polymer 2.0 to LitElement it can be easy to lament the loss of two-way binding in your templates. The double curly brace syntax (child-property="{{twoWayBoundProperty}}") made it easy for parent elements to track property changes in their children. This was managed under the covers by Polymer dispatching a Custom Event of child-property-changed from the child element in response to changes in the properties value, having the parent element listen for that event, and then applying that new value from the child element to the bound property in the parent. In principle this technique could be repeated in your LitElements by pairing the following bindings in the parent:

// parent-element.js

render() {
  return html`
      <child-element
        childProperty="${this.childProperty}"
        @child-property-changed="${this.handleChildPropertyChanged}"
      ></child-element>
  `;
}
handleChildPropertyChanged(e) {
  this.childProperty = e.detail.childProperty;
}
Enter fullscreen mode Exit fullscreen mode

With these changes to the child:

// child-element.js

updated(changedProperties) {
  if (changedProperties.has('childProperty')) {
    this.dispatch(new CustomEvent('child-property-changed', {
      bubbles: true, // propagates beyond self
      composed: true, // propagates through shadow boundaries
      detail: {
        childProperty: value
      }
    });
}
Enter fullscreen mode Exit fullscreen mode

Or similar changes via a getter/setter pair:

// child-element.js

get childProperty() {
  return this._childProperty;
}
set childProperty(value) {
  if (value === this._childProperty) return;
  let oldValue = this._childProperty;
  this._childProperty = value;
  this.dispatch(new CustomEvent('child-property-changed', {
    detail: {
      childProperty: value
    }
  });
  this.requestUpdate('childProperty', oldValue);
}
Enter fullscreen mode Exit fullscreen mode

This maybe your best bet in regards to the removal of two-way binding during this first pass at refactoring your elements with LitElement. In the future, I would greatly suggest you confirm that this sort of data traffic across your application is both achieving the goals that you have for your users and positioning your elements to scale into the future. Moving the data management up off of your components will likely make your code more maintainable and easier to test, so I would suggest researching the myriad of state management techniques that exist to support such a decision.

Common Template Realities

Working with Polymer 2 it is highly possible that you have things like dom-repeat (to manage lists of content) and dom-if (to manage conditional content) included in your templates. One of the most exciting parts of the move to LitElement is the flexibility that comes with having your template language expressed in JS. Taking advantage of this reality allows you to remove domain-specific language realities from your templates. Instead of a Polymer centric list of data like the following:

<dom-repeat items="[[users]]" as="user">
  <h1>[[user.name]]</h1>
  <p>[[user.address]]</p>
</dom-repeat>
Enter fullscreen mode Exit fullscreen mode

You can outline this in more JS centric terms, a la:

${users.map(user => html`
  <h1>${user.name}</h1>
  <p>${user.address}</p>
}
Enter fullscreen mode Exit fullscreen mode

And, your conditional rendering of content, a la:

<dom-if if="[[shouldShow]]>
  <p>Show an important thing.</p>
</dom-if>
Enter fullscreen mode Exit fullscreen mode

Can now be implemented as:

${shouldShow
  ? html`<p>Show an important thing.</p>
  : html``
}
Enter fullscreen mode Exit fullscreen mode

From there, having your templates managed in the JS space opens new options around template composition. Notice how the following breaks out the results of the branching logic into their own methods allowing us to think of our template in smaller and smaller pieces that are easier to reason about in isolation:

render() {
  return html`
    ${loaded
      ? this.renderLoaded()
      : this.renderLoading()
    }
  `;
}
renderLoaded() {
  return html`Loaded.`;
}
renderLoading() {
  return html`Loading...';
}
Enter fullscreen mode Exit fullscreen mode

This idea can be taken even further (or even possibly too far?) if you choose to move the entirety of your control flow into JS, leveraging a sort of strategies pattern, like the following:

get templatesByState() {
  return {
    LOADING: this.renderLoading,
    LOADED: this.renderLoaded
  }
}
render() {
  return this.templateByState[this.loadedState]();
}
renderLoaded() {
  return html`Loaded.`;
}
renderLoading() {
  return html`Loading...';
}
Enter fullscreen mode Exit fullscreen mode

These optimization and more are brought to you by lit-html which powers the template parsing and renderer processes of LitElement.

lit-html

Beyond these direct conversions, the move to LitElement means that your templates will now be powered by lit-html. An efficient, expressive, extensible HTML templating library for JavaScript, lit-html offers by default performance not previously seen in the tools available via the Polymer Project while offering a host of extended capabilities around data binding, data type support and control flow. All of which doesn’t even begin to get into the built-in directives that it offers, along with a powerful API it provides to develop directive of your own. With these powers combined, you are able to make more purposeful decisions about how to manage the rendering performance of your elements than ever before. As suggested by the Polymer Team, if you do work with “anything other than what lit-element re-exports” do yourself a solid and run yarn add lit-html@1.0.0 to make sure you’ve got that dependency base covered longterm.

Testing with Web Component Tester

When addressing our dependency management above you will have seen the inclusion of:

"wct-istanbub": "^0.2.1",
"wct-mocha": "^1.0.0"
Enter fullscreen mode Exit fullscreen mode

These updates make running web component tester in the browser easier and nicer than ever while providing support for test coverage reporting in an ES Module setting. With this update we’ll be looking to change the following in our testing entry point:

// test/index.html

<script src="../../webcomponentsjs/webcomponents-lite.js"></script>    <script src="../../web-component-tester/browser.js"></script>
Enter fullscreen mode Exit fullscreen mode

To:

<script src="../node_modules/mocha/mocha.js"></script>
<script src="../node_modules/wct-mocha/wct-mocha.js"></script>
Enter fullscreen mode Exit fullscreen mode

And, to leverage these changes in our individual test files we’ll want to change:

<script src="../../webcomponentsjs/webcomponents-lite.js"></script>
<script src="../../web-component-tester/browser.js"></script>
<script src="../../test-fixture/test-fixture-mocha.js"></script>
<link rel="import" href="../../polymer/polymer.html">

<link rel="import" href="../custom-element.html">
Enter fullscreen mode Exit fullscreen mode

To:

<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>

<script src="../node_modules/mocha/mocha.js"></script>
<script src="../node_modules/chai/chai.js"></script>
<script src="../node_modules/@polymer/test-fixture/test-fixture.js"></script>
<script src="../node_modules/wct-mocha/wct-mocha.js"></script>
<script src="../node_modules/sinon/pkg/sinon.js"></script>

<script type="module" src="../custom-element.js"></script>
Enter fullscreen mode Exit fullscreen mode

Between these two changes you can be sure that your tests are running in the newest environment and that the various APIs, you’ve come to depend on while writing tests are available when needed.

Accessibility

One of my absolute favorite benefits of working with Web Components Tester is their focus on accessibility testing. With the move to ES Modules comes a higher bar of quality when it comes to accessibility testing via axe-core, tools that live by a Manifesto:

  1. Automated accessibility testing rules must have a zero false-positive rate
  2. Automated accessibility testing rules must be lightweight and fast
  3. Automated accessibility testing rules must work in all modern browsers
  4. Automated accessibility testing rules must, themselves, be tested automatically

To take advantage of these tools, we’ll be updating our individual test files that include:

<script>
  describe('custom-element', () => {
    let element;
    a11ySuite('custom-element-tester');

    beforeEach(function() {
      element = fixture('custom-element-tester');
    });

  // ...
  });
</script>
Enter fullscreen mode Exit fullscreen mode

To:

<script type="module">
  import '../node_modules/axe-core/axe.min.js';
  import {axeReport} from '../node_modules/pwa-helpers/axe-report.js';
describe('custom-element', () => {
    let should = chai.should();
    let element;
    beforeEach(async () => {
      element = fixture('custom-element-tester');
      await element.updateComplete;
    });
    it('a11y', () => {
      return axeReport(element);
    });
    // ...
  });
</script>
Enter fullscreen mode Exit fullscreen mode

To ensure that our elements are fully upgraded and rendered before each test begins you’ll also see the inclusion of:

beforeEach(async () => {
  element = fixture('custom-element-tester');
  await element.updateComplete;
});
Enter fullscreen mode Exit fullscreen mode

LitElement renders asynchronously and awaiting that resolution of the first updateComplete promise will save you a lot of headaches down the road.

Conclusion

So far we’ve made some huge inroads into updating a custom element from Polymer 2 to LitElement. While discussing some of the philosophical differences, we’ve touch specifically on:

  • removing deprecated processes out of the element repository's lifecycle (CI, scripting, testing, building, etc.)
  • acquiring dependencies via NPM instead of Bower
  • applying those dependencies via ES Modules as opposed to HTML Imports
  • updating style applications to use the css template tag and the adoptedStyleSheets API
  • taking advantage of updated and extended capabilities of static get properties()
  • managing templates in JS and relying on the extended capabilities of lit-html
  • testing newly ES Module based elements

This hopefully leaves you feeling empowered to get deep into turning your Polymer 2 based custom elements up to Lit and the exciting possibilities of the LitElement base class. Go forth and make custom elements better, faster, and more often!


But, wait! This is really only the tip of the iceberg when it comes to both things that you might need to be updating from older approaches available in the Polymer 2 ecosystem, as well as things that are now possible when working in the LitElement ecosystem. When you get further into the process of making this upgrade (or simply making custom elements with the powerful LitElement base class), I hope you’ll share your experiences in the comments below. Whether you’ve been helped (or hurt, I’m always on the lookout for a good edit) by the suggestions outlined above, have a question about areas no fully covered herein, or have found something you think the rest of the community might be able to benefit in your work with LitElement, I want to hear from you. Only by sharing our experiences openly and often will we be able to discover and know the full depth of possibility on offer when using custom elements created from the LitElement base class.

Top comments (0)