0

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?

Sora2455
  • 744
  • 7
  • 25
  • Could you edit your code so that it will produce the behavior you're trying to solve? – Emiel Zuurbier Jan 13 '22 at 15:55
  • @EmielZuurbier The error doesn't happen when the script tag is run after the body is parsed (which is always is in StackOverflow snippets and JsFiddle) so I've added a Data url to the bug in action. – Sora2455 Jan 13 '22 at 21:50

1 Answers1

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

<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>
Danny '365CSI' Engelman
  • 16,526
  • 2
  • 32
  • 49
  • 1
    For the connectedCallback timing problem, there is [`HTMLParsedElement`](https://github.com/WebReflection/html-parsed-element) which fixes the missing `webComponentDOMFullyParsed()` callback (which I was so lucky to be able to contribute essential parts to). That being said, in four years of web components development what worked best all the time was to force the upgrade case by delaying the custom element registration to DOMContentLoaded using `defer`, and avoiding `document.write` completely. – connexo Jan 14 '22 at 23:22
  • @connexo `defer` seems like it wouldn't help if another of the same custom element was added to the DOM later, so looks like all my custom elements are going to need this "render on connect after delay" boilerplate, which is... disappointing. They really couldn't have just had `connectedCallback` fire after the light DOM was parsed? Or add another callback just for that? – Sora2455 Jan 15 '22 at 08:54
  • 1
    @Sora2455 From our experience, dynamically adding webcomponents later is not a problem as long as you don't use `document.write` to achieve that. – connexo Jan 15 '22 at 09:13
  • But let us not advice to whack on ``defer`` because we do not understand, nor master the technology. #### ``HTMLParsedElement`` does _basically_ the same as a ``setTimeout``, only with a lot more code. The whole point is to wait till the Event Loop is empty, so you know (ligh)DOM was parsed. #### Being able to dynamically add/upgrade Web Components is one of the great advantages over Frameworks; BUT, it does cause FOUCs you now (might) have to deal with. – Danny '365CSI' Engelman Jan 15 '22 at 10:02