How to have a 'connectedCallback' for when all child custom elements have been connected
Asked Answered
C

7

34

I'm using Web Components v1.

Suppose two Custom Elements:

parent-element.html

<template id="parent-element">
    <child-element></child-element>
</template>

child-element.html

<template id="child-element">
<!-- some markup here -->
</template>

I'm trying to use connectedCallback in parent-element to initialise the entire parent/child DOM structure when it is attached, which requires interaction with methods defined in child-element.

However, it seems child-element isn't properly defined at the time connectedCallback gets fired for customElement:

parent-element.js

class parent_element extends HTMLElement {
    connectedCallback() {
        //shadow root created from template in constructor previously
        var el = this.shadow_root.querySelector("child-element");
        el.my_method();
    }
}

This will not work, because el is an HTMLElement and not a child-element as expected.

I need a callback for parent-element once all child custom elements in its template have been properly attached.

The solution in this question does not seem to work; this.parentElement is null inside child-element connectedCallback().

ilmiont

Crimmer answered 7/2, 2018 at 12:21 Comment(1)
I came across a similar issue, except that despite the child element's connectedCallback() having been called, the parent element still had no access to the child element's ShadowRoot until the parent was inserted in the DOM. Fortunately, in Chrome at least, disconnectedCallback() is fired on the child element when the parent is removed.Bendite
B
21

Use slot elements in your ShadowDOM template.

Build your custom elements in a way so that they can live in any context, like as a child or parent element, without having any dependencies with other custom-elements. This approach will give you a modular design in which you can utilize your custom-elements in any situation.

But you still want to do something whenever a child element is present, like selecting them or calling a method on a child.

Slot elements

To tackle this the <slot> element has been introduced. With slot elements you can create placeholders inside your ShadowDOM template. These placeholders can be used by simply placing an element inside your custom-element as a child in the DOM. The child element will then be placed inside the position where the <slot> element is placed.

But how do you know if a placeholder has been filled with an element?

Slot elements can listen to a unique event called slotchange. This will be fired whenever an element is (or multiple elements are) placed on the position of the slot element.

Inside the listener of the event you can access all of the element in the placeholder with the HTMLSlotElement.assignedNodes() or HTMLSlotElement.assignedElements() methods. These return an array with the elements placed in the slot.

Now you can wait for the children to be placed inside the slot and do something with the children that are present.

This way allows you to only manipulate the DOM and leave the ShadowDOM alone and let it do its work. Just like you would do with regular HTML elements.

Will the event wait for all child elements to be connected?

Yes, the slotchange event is fired after all connectedCallback methods of the custom elements have been called. This means no racing conditions or missing setup when listening to the event.

class ParentElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <h2>Parent Element</h2>
      <slot></slot>
    `;
    console.log("I'm a parent and have slots.");
    
    // Select the slot element from the ShadowDOM..
    const slot = this.shadowRoot.querySelector('slot');
    
    // ..and listen for the slotchange event.
    slot.addEventListener('slotchange', (event) => {
      // Get the elements assigned to the slot..
      const children = event.target.assignedElements();
      
      // ..loop over them and call their methods.
      children.forEach(child => {
        if (child.tagName.toLowerCase() === 'child-element') {
          child.shout()
        }
      });
    });
  }
  
  connectedCallback() {
    console.log("I'm a parent and am now connected");
  }
}

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

class ChildElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <h3>Child Element</h3>
    `;
  }
  
  connectedCallback() {
    console.log("I'm a child and am now connected.");
  }

  shout() {
    console.log("I'm a child and placed inside a slot.");
  }

}

customElements.define('child-element', ChildElement);
<parent-element>
  <child-element></child-element>
  <child-element></child-element>
  <child-element></child-element>
</parent-element>
Broadfaced answered 12/10, 2019 at 14:10 Comment(3)
great! slotchange is useful!Listless
cool! You can safely delete all other answers on this page..Unsphere
In the case of a non-deferred script with child custom elements, slotchange gets called multiple times while the document is being parsed. In your example shout is called 12 times if the script is not deferred, and on each slotchange event event.target.assignedNodes() is different. If the script is deferred (e.g. by setting <script defer> or <script type="module">), there is one slotchange event and shout is called 3 times.Skiplane
L
8

There is a timing issue with connectedCallback It gets called, the first time, before any of its custom element children are upgraded. <child-element> is only an HTMLElement when connectedCallback is called.

To get at the upgraded child element you need to do it in a timeout.

Run the code below and watch the console output. When we try to call the child's method it fails. Again, this is because of the way Web Components are created. And the timing of when connectedCallback is called.

But, within the setTimeout the call to the child's method works. This is because you allowed time for the child element to get upgraded to your custom element.

Kinda stupid if you ask me. I wish there was another function that was called after all children were upgraded. But we work with what we have.

class ParentElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = '<h2>Parent Element</h2><child-element></child-element>';
  }
  
  connectedCallback() {
    let el = this.shadowRoot.querySelector("child-element");
    console.log('connectedCallback', el);
    try {
      el.childMethod();
    }
    catch(ex) {
      console.error('Child element not there yet.', ex.message);
    }
    setTimeout(() => {
      let el = this.shadowRoot.querySelector("child-element");
      console.log('setTimeout', el);
      el.childMethod();
    });
  }
}

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


class ChildElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = '<h3>Child Element</h3>';
  }

  childMethod() {
    console.info('In Child method');
  }
}

customElements.define('child-element', ChildElement);
<parent-element></parent-element>
Lipski answered 14/2, 2018 at 0:13 Comment(3)
Will it work if element would have a really big number of child nodes?Fukuoka
One thing I have done to know when my child elements are created and ready was to provide a set of events from the children that let the parent know when they are upgraded. Probably in their connectedCallback function. Then I knew when all the children aready.Lipski
Also thought of that. Someting along these lines would do the trick : new CustomEvent('child:initialized', { bubbles: true, composed: true }); and then an eventListener in the parent to execute the code relying on child custom elements.Fracture
C
5

After some more work, I have a solution of sorts.

Of course this.parentElement doesn't work in the child element; it's in the root of the shadow DOM!

My current solution, which is alright for my specific scenario, is as follows:

parent-element.js

init() {
    //Code to run on initialisation goes here
    this.shadow_root.querySelector("child-element").my_method();
}

child-element.js

connectedCallback() {
    this.getRootNode().host.init();
}

So in child element, we get the root node (template shadow DOM) and then its host, the parent element, and call init(...), at which point the parent can access the child and it's fully defined.

This solution isn't ideal for a couple of reasons, so I'm not marking it as accepted.

1) If there are multiple children to wait for, or deeper nesting, it's going to get more complicated to orchestrate the callbacks.

2) I'm concerned about the implications for child-element, if I want to use this element in a standalone capacity (i.e. somewhere else, entirely separate from being nested in parent-element) I will have to modify it to explicitly check whether getRootNode().host is an instance of parent-element.

So this solution works for now, but it feels bad and I think there needs to be a callback that fires on the parent when its entire DOM structure, including nested custom elements in its shadow DOM, is initialised.

Crimmer answered 7/2, 2018 at 13:0 Comment(2)
I'm afraid that parentElement isn't supported by modern-day browsers such as Netscape Navigator or IE 4. Don't use.Algar
This is an interesting solution -- thanks for thisCongruity
A
4

If you want to avoid having any visual glitches caused by the delay of a setTimeout, you could use a MutationObserver.

class myWebComponent extends HTMLElement 
{
      connectedCallback() {

        let childrenConnectedCallback = () => {
            let addedNode = this.childNodes[(this.childNodes.length - 1)];
            //callback here
        }

        let observer = new MutationObserver(childrenConnectedCallback);
        let config = { attributes: false, childList: true, subtree: true };
        observer.observe(this, config);

        //make sure to disconnect
        setTimeout(() => {
            observer.disconnect();
        }, 0);

     }
}
Asphyxiant answered 12/12, 2018 at 16:44 Comment(0)
C
3

Have a look at CustomElementRegistry.upgrade().
https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/upgrade
It forces constructor-calls for all custom children of the passed Node. You may even call this from inside your constructor!

class parent_element extends HTMLElement {
    connectedCallback() {
        customElements.upgrade (this); //<-- this solves your problem
        //shadow root created from template in constructor previously
        var el = this.shadow_root.querySelector("child-element");
        el.my_method();
    }
}
Cutinize answered 7/5, 2022 at 8:18 Comment(2)
To me this is the best solution. It solves the actual problem instead of mitigating it and there is no need to rewrite whole code. This seems to be exactly the case where upgrade() was made for.Stewart
This should be the accepted answer. I can confirm that, at the parent custom element's constructor, just after you add children custom elements instantiated from a text template, a single line call to customElements.upgrade(this); will fully hydrate the children - they will be available as instances of the element class you specified at customElements.define, even allowing you to invoke custom instance methods you implemented there.Maintopmast
S
1

We've run into very related problems, with children being unavailable in the connectedCallback of our custom elements (v1).

At first we tried to fix the connectedCallback using quite a complex approach that's also being used by the Google AMP team (a combination of mutationObserver and checking for nextSibling) which ultimately led to https://github.com/WebReflection/html-parsed-element

This unfortunately created problems of its own, so we went back to always enforcing the upgrade case (that is, including the script that registers the custom elements only at the end of the page, respectively on DOMContentLoaded, or using the defer attribute on the script tag).

Shenyang answered 26/2, 2019 at 15:45 Comment(0)
O
-1
document.addEventListener('DOMContentLoaded', defineMyCustomElements);

You can delay defining your classes until after the dom has been loaded.

Ozalid answered 4/10, 2018 at 15:24 Comment(3)
I haven't tested this outside of more than one page design, but it does solve the problem in at least a singular case for me. What are the reasons that this is a downvoted solution?Curvilinear
This is the solution out team has been working with for almost 3 years now. Not a single case of any problems whatsoever. (see my answer above).Shenyang
Probably downvoted because no explanation or example of how to use it. Putting my customElements.define( code inside it certainly did not work.Footworn

© 2022 - 2024 — McMap. All rights reserved.