I'm making a customised element that automatically localises it's visual text representation:
class LocalDate extends HTMLTimeElement {
// Specify observed attributes so that
// attributeChangedCallback will work
static get observedAttributes() {
return ["datetime"];
}
constructor() {
// Always call super first in constructor
const self = super();
this.formatter = new Intl.DateTimeFormat(navigator.languages, {
year: "numeric",
month: "short",
day: "numeric"
});
return self;
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "datetime") {
this.textContent = "";
const dateMiliseconds = Date.parse(newValue);
if (!Number.isNaN(dateMiliseconds)) {
const dateString = this.formatter.format(new Date(dateMiliseconds));
this.textContent = dateString;
}
}
}
}
customElements.define('local-date', LocalDate, {
extends: "time"
});
<time is="local-date" datetime="2022-01-13T07:13:00+10:00">13 Jan 2022 - Still here</time>
The kicker is when exactly the script tag is run - if it's run after the body is parsed, then it works as expected. Otherwise, instead of appearing as a date, the element displays the date string in addition to the text that was already in the element.
JsFiddle and StackOverflow both put the script tag at the bottom of the body, so the error can only be seen with a DataUrl:
data:text/html;charset=utf-8,%3C%21DOCTYPE%20html%3E%0D%0A%3Chtml%3E%0D%0A%3Chead%3E%0D%0A%3Cmeta%20charset%3D%22utf-8%22%2F%3E%0D%0A%3Ctitle%3ETime%20since%3C%2Ftitle%3E%0D%0A%3Cscript%3E%0D%0A%09class%20LocalDate%20extends%20HTMLTimeElement%20%7B%0D%0A%09%09%2F%2F%20Specify%20observed%20attributes%20so%20that%0D%0A%09%09%2F%2F%20attributeChangedCallback%20will%20work%0D%0A%09%09static%20get%20observedAttributes%28%29%20%7B%0D%0A%09%09%09return%20%5B%22datetime%22%5D%3B%0D%0A%09%09%7D%09%0D%0A%0D%0A%09%09constructor%28%29%20%7B%0D%0A%09%09%09%2F%2F%20Always%20call%20super%20first%20in%20constructor%0D%0A%09%09%09const%20self%20%3D%20super%28%29%3B%0D%0A%0D%0A%09%09%09this.formatter%20%3D%20new%20Intl.DateTimeFormat%28navigator.languages%2C%20%7B%20year%3A%20%22numeric%22%2C%20month%3A%20%22short%22%2C%20day%3A%20%22numeric%22%20%7D%29%3B%0D%0A%0D%0A%09%09%09return%20self%3B%0D%0A%09%09%7D%0D%0A%0D%0A%09%09attributeChangedCallback%28name%2C%20oldValue%2C%20newValue%29%20%7B%0D%0A%09%09%09if%20%28name%20%3D%3D%3D%20%22datetime%22%29%20%7B%0D%0A%09%09%09%09this.textContent%20%3D%20%22%22%3B%0D%0A%09%09%09%09const%20dateMiliseconds%20%3D%20Date.parse%28newValue%29%3B%0D%0A%09%09%09%09if%20%28%21Number.isNaN%28dateMiliseconds%29%29%20%7B%0D%0A%09%09%09%09%09const%20dateString%20%3D%20this.formatter.format%28new%20Date%28dateMiliseconds%29%29%3B%0D%0A%09%09%09%09%09%2F%2F%20Bizarrly%2C%20this%20doesn%27t%20seem%20to%20work%20without%20doing%20this%20in%20a%20timeout%3F%21%3F%21%0D%0A%09%09%09%09%09this.textContent%20%3D%20dateString%3B%0D%0A%09%09%09%09%7D%0D%0A%09%09%09%7D%0D%0A%09%09%7D%0D%0A%09%7D%0D%0A%09%0D%0A%09customElements.define%28%27local-date%27%2C%20LocalDate%2C%20%7B%20extends%3A%20%22time%22%20%7D%29%3B%0D%0A%3C%2Fscript%3E%0D%0A%3C%2Fhead%3E%0D%0A%3Cbody%3E%0D%0A%3Cp%3ELast%20updated%20%3Ctime%20is%3D%22local-date%22%20datetime%3D%222022-01-13T07%3A13%3A00%2B10%3A00%22%3E13%20Jan%202022%20-%20Still%20here%3C%2Ftime%3E%3C%2Fp%3E%0D%0A%3C%2Fbody%3E
I've reproduced this in both Firefox and Chrome - any ideas what's going on here?
Your issue occurs,
because BOTH attributeChangedCallback
and connectedCallback
fire on the opening tag
(and in this order!)
So
attributeChangedCallback
fires on the opening tag,.textContent
That is why you see the lightDOM #2
in the example below
<style>
time {
display: block
}
</style>
<time id=BEFORE is="local-date" datetime="2022-01-13T07:13:00+10:00"> lightDOM #1
<script>console.log("time element BEFORE parsed")</script>
</time>
<script>
customElements.define('local-date', class extends HTMLTimeElement {
static get observedAttributes() {
return ["datetime"];
}
attributeChangedCallback(name, oldValue, newValue) {
console.warn("attributeChangedCallback", this.id, this.isConnected);
this.textContent = (new Intl.DateTimeFormat(navigator.languages, {
year: "numeric",
month: "short",
day: "numeric"
})).format(new Date(Date.parse(newValue)));
}
}, {
extends: "time"
});
console.warn("Custom Element: local-date defined");
</script>
<time id=AFTER is="local-date" datetime="2022-01-13T07:13:00+10:00"> lightDOM #2
<script>console.log("time element AFTER parsed")</script>
</time>
attributeChangedCallback
So we have to make sure thetime is only rendered after all DOM is parsed, using a setTimeout
also see: wait for Element Upgrade in connectedCallback: FireFox and Chromium differences
and.. (but no role in the solution) because Apple has, since 2016, stated they will never implement Customized Built-In Elements, make it an Autonomous Element <local-date>
You could also add N kiloBytes more and use any of the 58 Tools for building Web Components, some shield you from this low level behaviour. But a Fool with a Tool, is still a Fool.
Good reference for when callbacks execute: https://andyogo.github.io/custom-element-reactions-diagram/
But attributeChangedCallback
DOES run before connectedCallback
!
Note how you can use this.isConnected
<style>
local-date {
display: block
}
</style>
<local-date id=BEFORE datetime="2022-01-13T07:13:00+10:00"> lightDOM #1
<script>console.log("time element BEFORE parsed")</script>
</local-date>
<script>
customElements.define('local-date', class extends HTMLElement {
static get observedAttributes() {
return ["datetime"];
}
attributeChangedCallback(name, oldValue, newValue) {
console.warn("attributeChangedCallback", this.id, this.isConnected);
if (oldValue) this.renderTime(newValue);
}
connectedCallback(){
console.warn("connectedCallback", this.id);
setTimeout(()=>this.renderTime());
}
renderTime(dt=this.getAttribute("datetime")){
console.warn("renderTime", this.id);
this.textContent = (new Intl.DateTimeFormat(navigator.languages, {
year: "numeric",
month: "short",
day: "numeric"
})).format(new Date(Date.parse(dt)));
}
});
console.warn("Custom Element: local-date defined");
</script>
<local-date id=AFTER datetime="2022-01-13T07:13:00+10:00"> lightDOM #2
<script>console.log("time element AFTER parsed")</script>
</local-date>