Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preventing re-renders prior to first call to connectedCallback() #1081

Open
br3nt opened this issue Oct 16, 2024 · 3 comments
Open

Preventing re-renders prior to first call to connectedCallback() #1081

br3nt opened this issue Oct 16, 2024 · 3 comments

Comments

@br3nt
Copy link

br3nt commented Oct 16, 2024

In my custom components with observable attributes, I've noticed that attributeChangedCallback() is called multiple times before connectedCallback() is called. Even though connectedCallback() hasn't been called yet, the value of isConnected in the earlier attributeChangedCallback() calls is true. This occurs when a custom element is defined directly on the HTML page.

The problem with this is there is no flag I can check to prevent unnecessarily re-rendering the element content before all attributes are initialised.

E.g.:

<body>
  <my-custom-element attr1="a" attrr2="b" attr3="c"
</body>

The example component may look like this:

class MyCustomElement extends HTMLElement {

  static get observedAttributes() {
    return ["attr1", "attr2", "attr3", "attr4", /* ... etc */ ];
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    this.render();
  }

  render() {
    // this doesn't prevent any unnecessary re-rerendering because the element is already connected
    // even though connectedCallback() hasn't been called yet
    if (!this.isConnected) return;

    this.innerHTML = renderFunction.render(this.attributes)
  }
}

In this example, attributeChangedCallback() is called three times, and this.isConnected is true each time. Finally, connectedCallback() is called.

This is problematic because I want to prevent unnecessary (re-)rendering of the element content until all the attributes defined in the HTML have been initialised due to the expense of computing the content for the element.

Now, I can manually add a flag and set it to true when connectedCallback() and false when disconnectedCallback() is called. But I'm pretty sure every developer using web components would want/need this flag, so it makes sense for this to be part of the web component specification.

Ideally, I would like one or both of:

  • A flag that is false before the call to connectedCallback() and true after the initial calls to attributeChangedCallback() for each of the defined attributes.
    • An appropriate flag name might be attributesInitialised or attributesInitialized
  • A callback that gets called between the initial calls to attributeChangedCallback() and the call to connectedCallback()
    • An appropriate callback name might be attributesInitialisedCallback or attributesInitializedCallback
@rniwa
Copy link
Collaborator

rniwa commented Oct 16, 2024

Might be related to #809.

@Danny-Engelman
Copy link

Danny-Engelman commented Oct 17, 2024

Yes, attributeChangedCallback runs for every Observed attribute declared on the Custom Element.
and before the connectedCallback

Attributes (may) drive UI (or other stuff your Element does) so they need to initialize before your Element presents itself in the DOM

Then,

From MDN isConnected:

The read-only isConnected property returns a boolean indicating whether the node is connected to a Document

It does NOT mean the connectedCallback ran

Maybe isDocumentConnected would have been a better property name... water under the bridge

Can be demonstrated with 2 Custom Element instances in a HTML document:

<my-element id="ONE">  lightDOM  </my-element>
<script>
  customElements.define( "my-element", class extends HTMLElement {
      constructor() {
        super()
        console.log("constructor", this.id, this.isConnected,  this.innerHTML.length)
      }
      connectedCallback() {
        console.log("connectedCallback", this.id, this.isConnected,  this.innerHTML.length)
      }
    },
  )
</script>
<my-element id="TWO">  lightDOM </my-element>

outputs:

  • ONE was parsed, so the constructor can access its attributes, its lightDOM, everything

  • TWO was not parsed yet, the constructor can NOT access DOM (that does not exist)

Note on #809: the connectedCallback fires on the opening tag, thus Custom Element ONE can reads its lightDOM in the connectedCallback (because it was parsed before ) and TWO can NOT. If you need that lightDOM you have to wait till it was parsed. setTimeout (1 LOC) is what I usually use, but you have to understand its intricacies. Lit, Shoelace, Solid, etc. etc. etc. all provide something like a parsedCallback. @WebReflection wrote a solid implementation (77 LOC) you can copy into your own BaseClass

Some developers will say to always do <script defer ... Yes, it "solves" the problem


Did connectedCallback ran?

Yes, that means you have to add your own semaphore for the connectedCallback

Instead of:

connectedCallback() {
  if (this.rendered) return
  this.renderOnce()
}
renderOnce() {
  this.rendered = true;
}

I sometimes hack it like this:

connectedCallback() {
  this.renderOnce()
  // render again code
}
renderOnce() {
  this.renderOnce = ( ) => {}
  // render once code
}

That way I don't need a semaphore in another method I later have to hunt for when refactoring


OPs wishlist:

Ideally, I would like one or both of:

  • A flag that is false before the call to connectedCallback() and true after the initial calls to attributeChangedCallback() for each of the defined attributes.
    * An appropriate flag name might be attributesInitialised or attributesInitialized

"attributes initialized" could be a lot of work, There will probably be a mismatch between Observed attributes and declared attributes.
You will (I think) have to do this yourself, because the majority of developers is not going to need this
And again, the connectedCallback by default runs after those attributeChangedCallback

  • A callback that gets called between the initial calls to attributeChangedCallback() and the call to connectedCallback()
    * An appropriate callback name might be attributesInitialisedCallback or attributesInitializedCallback

There is no "in between"
The connectedCallback will run after all (observed attribute) attributeChangedCallbacks; so you know all attributes (declared on the Element in the DOM! Not ALL attributes) have initialized
Keep a eye on the oldValue, NULL will tell you an Observed attribute makes its first appearance in the attributeChangedCallback.. which can be 5 minutes later when your user triggers a new Observed attribute.

HTH

@WebReflection
Copy link

WebReflection commented Oct 17, 2024

it's not just re-rendering though ... connectedCallback doesn't guarantee you can set innerHTML in the element because the element is still parsing and not necessarily closed, unless is one of those elements that has no content, still ...

this.innerHTML = renderFunction.render(this.attributes)

this fails if your custom element declaration happens before the element is discovered/streamed in the DOM.

this works if your element is already live, meaning your declaration happened after the DOM was already parsed.

The latter point means you are using modules to define custom elements and modules play (as side-effect) better because they exec on DOMContentLoaded state of the dom, but if your definition is in a synchronous script tag before the rest of the DOM is parsed you'll have surprises there ... innerHTML can't be used.

This is the reason we've been asking a lot a way to have the possibility to operate on Custom Elements once their end tag is either reached or implicitly enforced by the parser, but the issue here is pretty normality from a parser point of view:

<custom-element attr="1">
  Maybe content
</custom-element>

A parser would find the node, which is already connected, and it will start parsing <custom-element attr="1"> which happens before the content of such node is even known.

Then it passes to its childNodes, if any, and right before that, it will trigger connectedCallback already, but the node is in a weird state:

  • it was already live, everything is fine, still the parser is making sense of it
  • it's just discovered, the parser at that point doesn't even know what's the node content, so textContent or innerHTML operations will be forbidden until these can operate

These are the basics behind Custom Elements to know though, otherwise much more issues could be found in the "shipping".

TL;DR is any callback except for disconnectedCallback reliable on current Custom Elements specification when it comes to infer the state of the node that is in the discovery phase rather than already live? No.

P.S. if you use attachShadow none of these concerns or issues are present ... Shadow DOM is a complete different beast fully detached from the light-DOM concept ... Shadow DOM is also most of the time overkill or undesired but it's a tad late to complain, even if I've done that for literally ages 🤷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants