Can't set textContent of a customised built-in element except in timeout

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?


Solution 1:

Your issue occurs,
because BOTH attributeChangedCallback and connectedCallback fire on the opening tag
(and in this order!)

So

  • when the Custom Element is defined BEFORE being used in the DOM,
  • the attributeChangedCallback fires on the opening tag,
  • adds your .textContent
  • after that the lightDOM from your Custom Element is parsed
  • and by default added to the existing content

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>

Do not do initialisation in the 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>