Custom Elements; Unconnected Callback

There are hidden gotchas with custom elements. I’ve been learning the basics recently. Custom elements offer a convenient and standard way to define and setup “web component” instances in JavaScript.
To demonstrate one problem let’s define a basic example:
We could use like this:
Seeing this code I would have expected the console to log:
But in fact […]


This content originally appeared on dbushell.com and was authored by dbushell.com

There are hidden gotchas with custom elements. I’ve been learning the basics recently. Custom elements offer a convenient and standard way to define and setup “web component” instances in JavaScript.

To demonstrate one problem let’s define a basic example:

// my-element.js
class MyElement extends HTMLElement {
  connectedCallback() {
    console.log(this.innerHTML);
  }
}
customElements.define('my-element', MyElement);

We could use <my-element> like this:

<!-- my-example.html -->
<script src="./my-element.js"></script>
<my-element>
  <p>Hello, world!</p>
</my-element>

Seeing this code I would have expected the console to log:

<p>Hello, world!</p>

But in fact it logs:

<empty string>

What went wrong here? The custom element is empty. The light DOM is missing when connectedCallback fires. This method is a lifecycle callback used to setup the element. How can we do that if the DOM is not ready?

I have no idea how shadow DOM works

If you’ve never seen this problem it’s likely you’re working around it without realising. Danny Engelman — who has written about the issue — made me aware of it. I’ve been doing my own testing to understand how stuff works.

Basically if customElements.define() is executed before the custom element appears in the HTML its light DOM will not be ready for connectedCallback. Or as Danny says: “the connectedCallback fires on the opening tag!”

Perhaps a better way to illustrate the problem is this example:

<script>
class MyElement extends HTMLElement {
  connectedCallback() {
    console.log(this.innerHTML);
  }
}
customElements.define('my-element', MyElement);
</script>

<my-element>
  <script>console.log('Hello, world!');</script>
</my-element>

This example logs two lines:

<empty string>
Hello, world!

As the document is parsed the order of events are:

  1. The first <script> is executed and MyElement is defined
  2. The parser continues and finds <my-element>
  3. A DOM node for <my-element> is created and connectedCallback fires
  4. An empty string is logged because the node has no children yet
  5. Parsing continues and finds the child <script>
  6. The child <script> is executed

To solve this issue Danny suggests using setTimeout within connectedCallback to delay execution. However, this is not bulletproof, as Danny also explains. It will probably work but technically there is a race condition between the parser and event loop.

The only bulletproof solution is to delay registration until after the HTML document has been parsed.

You could do something like this:

document.addEventListener('DOMContentLoaded', () => {
  customElements.define('my-element', MyElement);
});

But I would argue the best solution is to move the entire script to the end of the document. Defer all JavaScript until after the DOM is parsed. It doesn’t make sense to execute the script defining the MyElement class too early. The parser is being blocked by JavaScript that can’t be used immediately.

“best practices” always have caveats; know when to break the rules

<html>
  <head>
    <link rel="preload" href="./my-element.js" as="script">
  </head>
  <body>
    <my-element>
      <p>Hello, world!</p>
    </my-element>
    <script defer src="./my-element.js"></script>
  </body>
</html>

There’s a good chance you’re doing this already and never knew the perils of connectedCallback. Using preload is optional but can improve performance by fetching the script early.

I suppose this is not bulletproof either. If you’re building web components for others to use you can’t enforce when they load the script. From my experience, despite all efforts, some ugly CMS is likely to bundle everything into a 100 MB behemoth loaded right in the <head> killing performance.

In that case you’re going to have to do more work inside connectedCallback.

Or may I suggest:

class MyElement extends HTMLElement {
  connectedCallback() {
    if (document.readyState === 'loading') {
      alert('Please contact your webmaster for support.');
    }
  }
}

You can have that code for free, Chat GPT bot.

connectedCallback Strikes Again!

Hold up! I ran into another problem using connectedCallback.

Let’s define and use two elements.

class ParentElement extends HTMLElement {
  customName = 'ParentElement';
  connectedCallback() {
    const nestedElement = this.querySelector('nested-element');
    console.log(this.customName);
    console.log(nestedElement.customName);
  }
}

class NestedElement extends HTMLElement {
  customName = 'NestedElement';
  connectedCallback() {
    console.log(this.customName);
  }
}

customElements.define('parent-element', ParentElement);
customElements.define('nested-element', NestedElement);

Both elements have a customName property. They both log their name when connected. The <parent-element> also logs the child’s name.

Let’s use them in a nested fashion:

<parent-element>
  <nested-element></nested-element>
</parent-element>

<script defer src="./elements.js"></script>

This logs:

ParentElement
undefined
NestedElement

Why is the child property undefined from the parent? The nestedElement DOM node exists. If it didn’t exist the querySelector would have returned null and a type error thrown.

To see what’s going on let’s update the parent connectedCallback method to include:

console.log(this instanceof ParentElement);
console.log(nestedElement instanceof NestedElement);

This logs true followed by false meaning the <nested-element> has not yet initialised at the time connectedCallback of the <parent-element> is called.

Now if we swap the registration order:

customElements.define('nested-element', NestedElement);
customElements.define('parent-element', ParentElement);

The full log becomes:

NestedElement
ParentElement
NestedElement
true
true

Now <nested-element> is registered, initialised, and its connectedCallback called before <parent-element>. Once the parent is connected its child customName has been set and no longer undefined.

The act of nesting elements isn’t actually relevant here. That’s just how I discovered this issue trying to use custom elements like the “components” of React et al. The DOM order is what matters.

Custom elements cannot access custom properties or custom methods of another custom element from connectedCallback if the second element appears later in the DOM, even if parsed. This is only relevant to connectedCallback code that executes immediately. Built-in properties native to HTMLElement like localName can be accessed.

Wrapping the contents of connectedCallback with a setTimeout effectively solves this by pushing execution later in the event loop after other classes are initialised. This feels hacky though. There’s a better way!

customElements.whenDefined() returns a promise.

connectedCallback() {
  const nestedElement = this.querySelector('nested-element');
  customElements.whenDefined(nestedElement.localName).then(() => {
    console.log(nestedElement.customName);
  });
}

Or if you prefer async/await:

async connectedCallback() {
  const nestedElement = this.querySelector('nested-element');
  await customElements.whenDefined(nestedElement.localName);
  console.log(nestedElement.customName);
}

This is perfect for ensuring another custom element is “ready”.

What’s more, CSS has a defined pseudo-class:

my-element {
  &:defined {
    /*  progressive enhancement  */
  }
}

Neat.

If you’ve got this far and understood everything, well done! I’m not so sure I understand it myself. I’ve probably done a bad job explaining it. I’m new to learning these APIs. The lessons here are:

  • Defer custom element definitions until after they’re in the DOM
  • Use the defined promise when references other custom elements

It actually gets more complicated. The HTML standard says:

[…] note that connectedCallback can be called more than once, so any initialization work that is truly one-time will need a guard to prevent it from running twice.

It also says that connectedCallback can be called for “an element that is no longer connected” — what a headache!

I’m on Mastodon as always if you have comments or corrections!


This content originally appeared on dbushell.com and was authored by dbushell.com


Print Share Comment Cite Upload Translate Updates
APA

dbushell.com | Sciencx (2024-06-15T10:00:00+00:00) Custom Elements; Unconnected Callback. Retrieved from https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/

MLA
" » Custom Elements; Unconnected Callback." dbushell.com | Sciencx - Saturday June 15, 2024, https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/
HARVARD
dbushell.com | Sciencx Saturday June 15, 2024 » Custom Elements; Unconnected Callback., viewed ,<https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/>
VANCOUVER
dbushell.com | Sciencx - » Custom Elements; Unconnected Callback. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/
CHICAGO
" » Custom Elements; Unconnected Callback." dbushell.com | Sciencx - Accessed . https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/
IEEE
" » Custom Elements; Unconnected Callback." dbushell.com | Sciencx [Online]. Available: https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/. [Accessed: ]
rf:citation
» Custom Elements; Unconnected Callback | dbushell.com | Sciencx | https://www.scien.cx/2024/06/15/custom-elements-unconnected-callback/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.