11

I have a reference to an element that will at some point be upgraded to a custom element. How do I wait for it to be upgraded?

For example, suppose el is the reference. If it hypothetically had a promise attached to it for this purpose, code could be similar to

await el.upgradePromise
// do something after it has been upgraded.

That of course doesn't exist, but describes what I want to do. Maybe there is no way to do it without polling? If I use polling, what would I poll for (suppose I don't have a reference to the class constructor that it should upgrade to). Maybe I can poll el.constructor and wait for it not to be HTMLElement, or wait for it not to be HTMLUnknownElement?

EDIT: for background, I have some code like the following, where using setTimeout is a hack in order for the code to work. The first console.log outputs false, while the one in the timeout outputs true.

import OtherElement from './OtherElement'

class SomeElement extends HTMLElement {
    attachedCallback() {
        console.log(this.children[0] instanceof OtherElement) // false

        setTimeout(() => {
            console.log(this.children[0] instanceof OtherElement) // true
        }, 0)
    }
}

where OtherElement is a reference to a Custom Element class that will at some point be registered. Note, I'm using Chrome v0 document.registerElement in my case. The timeout is needed because if SomeElement is registered first as in the following code, then OtherElement will not yet be registered, so therefore if the child of the SomeElement element is expected to be an instance of OtherElement, then that will not be the case until those elements are upgraded next.

document.registerElement('some-el', SomeElement)
document.registerElement('other-el', OtherElement)

Ideally, a timeout like that is undesirable, because if upgrade happens to take a longer time (for some unknown reason that could depend on the browser implementation) then the timeout hack will also fail.

I'd like an absolute way to wait for something to be upgraded without possible failure, and without polling if possible. Maybe it needs to be canceled after some time too?

EDIT: The ideal solution would allow us to wait for the upgrade of any third-party custom elements without needing to modify those elements before runtime, and without having to monkey patch then at runtime.

EDIT: From observing Chrome's v0 behavior, it seems like the first call to document.registerElement('some-el', SomeElement) causes those elements to be upgraded and their attachedCallback methods to be fired before the registration of OtherElement, so the children will not be of the correct type. Then, by deferring logic, I can run logic after the children have also been upgraded to be of type OtherElement.

EDIT: Here's a jsfiddle that shows the problem, and here's a jsfiddle that shows the timeout hack solution. Both are written with Custom Elements v1 API in Chrome Canary, and won't work in other browsers, but the problem is the same using Chrome Stable's Custom Elements v0 API with document.registerElement and attachedCallback instead of customElements.define and connectedCallback. (See console output in both fiddles.)

trusktr
  • 44,284
  • 53
  • 191
  • 263
  • you could dispatch a custom event or set a Promise from the connectedCallback. – Supersharp Aug 29 '16 at 06:43
  • 1
    @Supersharp That would work if the elements I'm waiting for were mine. But, what if we are using third-party elements and don't want to fork those elements perse (suppose they are installed as a package from NPM). The solution would ideally allow us to wait for the upgrade of any third-party custom elements without having modified those elements before runtime, and without having to monkey patch then at runtime. – trusktr Aug 29 '16 at 20:36
  • 2
    I'm not sure how you could do this in V0, but in V1 the `customElement` object provides the `whenDefined` method, which takes a `tagName`, and returns a Promise that resolves when a customElement with that `tagName` is defined. You could do `window.customElement.whenDefined(e.tagName).then(() => console.log('defined'))` – Jesse Hattabaugh Aug 29 '16 at 23:42
  • @arkanciscan That works, except that I don't know what tag name the end user of my custom element class will define. I am encouraging end users to use `customElements.define` to define and use the custom elements. So, that won't work in that case. – trusktr Sep 25 '16 at 07:37
  • 1
    @trusktr please see the answer I just added—it shows how you can easily get when elements are defined/undefined/upgraded without personally knowing the `localName` of these custom elements ahead of time :) – james_womack Dec 14 '17 at 01:18

4 Answers4

10

You can use window.customElements.whenDefined("my-element") which returns a Promise that you can use to determine when an element has been upgraded.

window.customElements.whenDefined('my-element').then(() => {
  // do something after element is upgraded
})
stackt52
  • 109
  • 1
  • 6
  • 2
    This solution doesn't work because as a library author, I don't know the end user's chosen element names, so I don't know what to pass into the `whenDefined` call. This is only useful for the person defining the name of the elements. I am making a WebComponent base class that does not know what name subclasses will be assigned to. This is an ugly problem, and I wonder if maybe I'm doing it wrong. Maybe I need to have subclass authors define the name of the element (or instance) on a class property or something. – trusktr Apr 17 '17 at 20:39
  • @trusktr see updated edit with generic catch-all to expand on – jimmont Feb 16 '20 at 01:01
2

Because of sync order of html and javascript parsing, you just have to wait for the elements to be defined and inserted.

1st Test Case - HTML inserted then element defined:

<el-one id="E1">
  <el-two id="E2">
  </el-two>
</el-one>
<script>
  // TEST CASE 1: Register elements AFTER instances are already in DOM but not upgraded:
  customElements.define('el-one', ElementOne)
  customElements.define('el-two', ElementTwo)
  // END TEST CASE 1
  console.assert( E1 instanceof ElementOne )
  console.assert( E2 instanceof ElementTwo )
</script>

2nd Test Case - element defined then inserted:

// TEST CASE 2: register elements THEN add new insances to DOM:
customElements.define('el-three', ElementThree)
customElements.define('el-four', ElementFour)
var four = document.createElement('el-four')
var three = document.createElement('el-three')
three.appendChild(four)
document.body.appendChild(three)
// END TEST CASE 2
console.assert( three instanceof ElementThree )
console.assert( four instanceof ElementFour )

class ElementZero extends HTMLElement {
    connectedCallback() {
        console.log( '%s connected', this.localName )
    }
}

class ElementOne extends ElementZero { }
class ElementTwo extends ElementZero { }

// TEST CASE 1: Register elements AFTER instances are already in DOM but not upgraded:
customElements.define('el-one', ElementOne)
customElements.define('el-two', ElementTwo)
// END TEST CASE 1
console.info( 'E1 and E2 upgraded:', E1 instanceof ElementOne && E2 instanceof ElementTwo )


class ElementThree extends ElementZero { }
class ElementFour extends ElementZero { }

// TEST CASE 2: register elements THEN add new insances to DOM:
customElements.define('el-three', ElementThree)
customElements.define('el-four', ElementFour)
const E4 = document.createElement('el-four')
const E3 = document.createElement('el-three')
E3.appendChild(E4)
document.body.appendChild(E3)
// END TEST CASE 2
console.info( 'E3 and E4 upgraded:', E3 instanceof ElementThree && E4 instanceof ElementFour )
<el-one id="E1">
  <el-two id="E2">
  </el-two>
</el-one>

3rd Test Case - unknown element names

If you don't know what are the name of the inner elements, you could parse the content of the outer element and use whenDefined() on every discovered custom element.

// TEST CASE 3
class ElementOne extends HTMLElement {
  connectedCallback() {
    var customs = []
    for (var element of this.children) {
      if (!customs.find(name => name == element.localName) &&
        element.localName.indexOf('-') > -1)
        customs.push(element.localName)
    }
    customs.forEach(name => customElements.whenDefined(name).then(() => 
      console.log(name + ' expected to be true:', this.children[0] instanceof customElements.get(name))
    ))
  }
}

class ElementTwo extends HTMLElement {}
customElements.define('el-one', ElementOne)
customElements.define('el-two', ElementTwo)
<el-one>
  <el-two>
  </el-two>
</el-one>

Note If you have to wait for different custom elements to be upgraded you'll to to way for a Promise.all() resolution. You may also want to perform a more elaborated (recursive) parsing.

Supersharp
  • 29,002
  • 9
  • 92
  • 134
  • 1
    Thanks Supersharp. My problem is however that I am making a base `WebComponent` class that has some feature, where the feature needs to run logic on children *after* they are upgraded. As a library author, I am not writing the end-user markup, nor am I controlling order of execution. I just need a way to way for the end user's child elements to be upgraded, without knowing what they are named (for example I can't use `customElements.whenDefined` because I don't know what the child elements are called. Maybe I need the end-user to specify the element names somehow. – trusktr Apr 17 '17 at 20:52
  • Parsing is an interesting trick, though expensive. Thanks for sharing that idea! – trusktr Dec 15 '17 at 03:03
  • Please be aware that this approach won't solve the cases of __customized__ built-in elements, since their localname is just a standard element's name and won't be picked up by that dash-test detection. – GullerYA Sep 20 '18 at 07:27
2

@trusktr if I understand your question properly, this is absolutely possible through creative use of the :defined pseudo-selector, MutationObserver & the custom elements methods you've already mentioned

const o = new MutationObserver(mutationRecords => {
  const shouldCheck = mutationRecords.some(mutationRecord => mutationRecord.type === 'childList' && mutationRecord.addedNodes.length)

  const addedNodes = mutationRecords.reduce((aN, mutationRecord) => aN.concat(...mutationRecord.addedNodes), [])

  const undefinedNodes = document.querySelectorAll(':not(:defined)')

  if (shouldCheck) { 
    console.info(undefinedNodes, addedNodes);

    [...undefinedNodes].forEach(n => customElements.whenDefined(n.localName).then(() => console.info(`${n.localName} defined`)))
  }
})

o.observe(document.body, { attributes: true, childList: true })

class FooDoozzz extends HTMLElement { connectedCallback () { this.textContent = 'FUUUUUCK' }  }

// Will tell you that a "foo-doozzz" element that's undefined has been added
document.body.appendChild(document.createElement('foo-doozzz'))

// Will define "foo-doozzz", and you event fires telling you it was defined an there are no longer any undefined elements
customElements.define('foo-doozzz', FooDoozzz)

// You'll see an event fired telling you an element was added, but there are no undefined elements still
document.body.appendChild(document.createElement('foo-doozzz'))
james_womack
  • 10,028
  • 6
  • 55
  • 74
  • 1
    This is interesting. Any chance you can take the two fiddles at the bottom of my question, and make it work with your solution instead of the timeout? – trusktr Dec 15 '17 at 03:01
  • Note, in the class code, make the assumption that we do not know the name of the element we are checking (f.e., we know only the class is ElementTwo, but we do not know that it will be defined as "el-two", and the parent ElementOne wants to detect that the child is ElementTwo after it was upgraded) – trusktr Dec 15 '17 at 03:06
  • Basically my question is, how would you replace the setTimeout with the MutationObserver version? – trusktr Dec 15 '17 at 03:07
1

Raising it again, since the original problem has not been answered and there probably a case for a spec enhancement here.

I, basically, have the same problem as an OP: in a framework code working on DOM elements I need to detect an undefined yet one and postpone its processing till when defined.

element.matches(':defined') as proposed by james_womack (and elsewhere) is a good start, since it eliminates the need of determining if the given element is custom/customized, which is not interesting in itself.

This approach solves the issue for custom elements:customElements.whenDefined(element.localName).

This is insufficient for customized built-in elements, since local name will be just a standard node name.

Preserving is attribute's value on the element would solve this issue, but it is not required by the spec today.

Therefore when performing the code below: let element = document.createElement('input', {is: 'custom-input'}); the is attribute gets lost.

BTW, when doing effectively the same thing as following, the is attribute is preserved: document.body.innerHTML += '<input is="custom-input"/>'.

IMHO, is attribute should be preserved also in the programmatic creation flow, thus giving a consistent APIs behavior and providing an ability of waiting for definition of customized built-in elements.

Appendix:

GullerYA
  • 1,320
  • 14
  • 27