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 · 6 comments
Open

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

br3nt opened this issue Oct 16, 2024 · 6 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 🤷

@br3nt
Copy link
Author

br3nt commented Oct 20, 2024

Thank you everyone for the in-depth explanations. I need to do some experimentation to see if I'll be affected by some of these cases due to the specific way I'm creating custom elements.

For background, I am building a templating library that allows mixing JS and HTML to create custom elements similar to what you see in back-end template libraries like Ruby's ERB, and ASP.NETs Razor where native code is mixed with HTML. I'm following in the vein of no-build like HTMX, but with a more client-side focus and using actual JS as the script for interactivity. Effectively, I'm creating a declarative way of writing custom elements. Something that can't currently be done with <template> alone. Ideally, something along these lines would be natively supported/implemented by browsers. I suspect I'll encounter several roadblocks on this journey.

An example I have working is:

<html>
<head>
  <script type="module" src="../jst.js"></script>
</head>

<script type="jst" name="jst-counter" count :increment>
  $ const clicks = count == 1 ? 'click' : 'clicks'
  <button onmousedown="$increment">$(count || 0) $clicks</button>
</script>

<body>
    <jst-counter count="5" :increment="incrementCounter(this)"></jst-counter>

    <script>
      function incrementCounter(el) {
        ++el.attributes.count.value
      }
    </script>
</body>

Ideally, the user shouldn't have to use $ to switch between JS mode and HTML mode, however, I don't want to write an entire parse/interpreter, so I have opted to use a similar style used by back-end template renderers.

I next want to implement slots, however, based on the feedback from @Danny-Engelman and @WebReflection, I now suspect I will encounter issues with this:

<html>
<head>
  <script type="module" src="../jst.js"></script>
</head>

<script type="jst" name="jst-condition" condition>
  $ if (condition) {
    <slot name="true"></slot>
  $ } else {
    <slot name="false"></slot>
  $ }
</script>

<body>
    <script>
      const someVar = true;
    </script>

    <jst-condition condition="someVar">
      <span slot="true">someVar was true</span>
      <span slot="false">someVar was false</span>
    </jst-condition>
</body>

The user should be able to do anything custom elements can, like extend other classes, and the myriad of other custom element features that exist:

<script type="jst" name="jst-checkbox" extend="HTMLInputElement" extend-type="HTMLInputElement">
   ...content...
</script>

@justinfagnani
Copy link
Contributor

@br3nt you can do conditional DOM with <template>s. Use those as the containers for branches, similar to blocks in JS.

So instead of:

<script type="jst" name="jst-condition" condition>
  $ if (condition) {
    <slot name="true"></slot>
  $ } else {
    <slot name="false"></slot>
  $ }
</script>

Try:

<template type="jst" name="jst-condition" condition>
  <template type="if">
    <slot name="true"></slot>
  </template>
  <template type="else">
    <slot name="false"></slot>
  </template>
</script>

(using whatever attribute names you want to signify the specific functionality of each <template> element).

My project Heximal (declarative HTML templates, custom elements, variables, and more) uses this technique: https://github.com/elematic/heximal/?tab=readme-ov-file#features

I'm happy to talk more about this on the WCCG Discord or more on-topic issues here :)

@br3nt
Copy link
Author

br3nt commented Oct 21, 2024

Thanks @justinfagnani for taking an interest.

In this case, I want to stick with mixing JS and HTML. The problem I foresee isn't the template parsing, as I have that worked out. I will encounter the specific problem mentioned by @WebReflection regarding the inability to assign to this.innerHtml before the parser closes the custom HTML tag.

So I'm a plus one for:

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

Having a callback and hopefully a flag to validate I can assign to this.innerHtml would be ideal and would also solve my original problem described in the description.

I'm concerned the <script defer or setTimeout() solutions/hacks mentioned by @Danny-Engelman introduce a greater degree of uncertainty and unpredictability for the timing of script execution.

@justinfagnani, I'd like to reach out, because looking briefly at your library, I think we will encounter many of the same problems you may have already tackled. (edit... hmmm cant find the discord lol)

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

5 participants