It’s been well-documented that one of the most awkward parts of working with custom elements is handling properties and attributes. In this post, I want to go a step further and talk about a tricky situation with properties and the component lifecycle.
The problem
First off, see if you can find the bug in this code:
<hello-world></hello-world> <script src="./hello.js" type="module"></script> <script> document.querySelector('hello-world').mode = 'dark' </script>
And here’s the component we’re loading, which is just a “hello world” that switches between dark and light mode:
// hello.js customElements.define('hello-world', class extends HTMLElement { constructor() { super() this.innerHTML = '<div>Hello world!</div>' } set mode (mode) { this.querySelector('div') .setAttribute('style', mode === 'light' ? 'background: white; color: black;' : 'background: black; color: white;' ) } })
Do you see it? Don’t worry if you missed it; it’s extremely subtle and took me by surprise, too.
The problem is the timing. There are two <script>
s – one loading hello.js
as a module, and the other setting the mode
property on the <hello-world>
element. The problem is that the first <script>
is type="module"
, meaning it’s deferred by default, whereas the second is an inline script, which runs immediately. So the first script will always run after the second script.
In terms of custom elements, this means that the set mode
setter will never actually get called! The HTML element goes through the custom element upgrade process after its mode
has already been set, so the setter has no impact. The component is still in light mode.
Note: Curiously, this is not the case for attributes. As long as we have observedAttributes
and attributeChangedCallback
defined in the custom element, we’ll be able to handle any attributes that existed before the upgrade. But, in the tradition of funky differences between properties and attributes, this isn’t true of properties.
The fix
To work around this issue, the first option is to just do nothing. After all, this is kind of an odd timing issue, and you can put the onus on consumers to load the custom element script before setting any properties on it.
I find this a bit unsatisfying, though. It feels like it should work, so why shouldn’t it? And as it turns out, there is a fix.
When the custom element is defined, all existing HTML elements are upgraded. This means they go through the constructor()
callback, and we can check for any existing properties in that block:
constructor() { /* ... */ if (Object.prototype.hasOwnProperty.call(this, 'mode')) { const mode = this.mode delete this.mode this.mode = mode } }
Let’s break it down step-by-step:
Object.prototype.hasOwnProperty.call(this, 'mode')
Here we check if we already have a property defined called mode
. The hasOwnProperty
is necessary because we’re checking if the object has its own mode
as opposed to the one it gets from the class (i.e. its prototype).
The Object.prototype
dance is just an ESLint-recommended safety measure. Using this.hasOwnProperty
directly is probably fine too.
const mode = this.mode delete this.mode
Next, we cache and delete the mode
that was set on the object. This way, the object no longer has its own mode
property.
this.mode = mode
At this point, we can just set the mode
and the setter from the prototype (set mode
) will be invoked.
Here is a full working example if you’re curious.
Conclusion
Properties and attributes are an awkward part of working with web components, and this is a particularly tricky situation. But it’s not impossible to work around, with just a bit of extra constructor
code.
Also, you shouldn’t have to deal with this unless you’re writing your own vanilla custom element, or a wrapper around a framework. Many frameworks have built-in support for building custom elements, which means they should handle this logic automatically.
For more reading on this topic, you can check out Google’s Web Fundamentals or take a look at how Lit and Stencil handle this situation.
Posted by miltonhowebell on March 23, 2023 at 9:09 AM
Thank you! I have been playing with and using custom elements for a while but I had no idea about this timing problem until I read this post.