WebReflection / uhtml

A micro HTML/SVG render

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Custom Element setters no longer called when uhtml renders them with a direct attribute

ilovecomputers opened this issue · comments

I have a project where I modify the DOM of a custom component when it receives a complex object as its property.1 Direct DOM manipulation is starting to look tedious for a list management tool, so I decided to adopt uhtml into my project.

When I passed in the complex object into a component via uhtml's direct attributes, the setter stopped being called and I didn't see a list being rendered. I dug into it some more and saw that uhtml sets the element property before the custom element's constructor is called. Due to prototypical inheritance, the property that uhtml sets overrides the setter initialized in the element's prototype.2

Right now I have a workaround where I delete that property in the constructor. This allows the setter in the prototype to be callable.

Is uhtml's direct attribute supposed to set an element property before that element's constructor is called?

Footnotes

  1. Of course I can't pass complex objects into a component via HTML attributes unless I want to create a JSON.stringify with a replacer function that can handle a Map object.

  2. Even in a browser's element inspector, when I call $0.listWithAccounts = ..., the setter still doesn't get called and the custom element remains empty.

if the definition of the custom element is after, you have the exact same problem with native dom, example:

const ce1 = document.createElement('c-e');
ce1.ping = 'first';

// lazy CE definition
customElements.define('c-e', class extends HTMLElement {
  #ping;
  get ping() { return this.#ping }
  set ping(value) {
    // never happens on ce1
    // happens on ce2
    console.log('setting', value);
    this.#ping = value;
  }
});

const ce2 = document.createElement('c-e');
ce2.ping = 'second';

A common way to survive lazy custom elements definitions is to check if the property is own at the constructor level and delete it to then re-pass it as desired.

class CustomElement extends HTMLElement {
  #ping;
  constructor() {
    if (super().hasOwnProperty('ping')) {
      const value = this.ping;
      delete this.ping;
      this.ping = value;
    }
  }
  get ping() { return this.#ping }
  set ping(value) {
    console.log('setting', value);
    this.#ping = value;
  }
}

This is not uhtml specific though, this is how custom elements work in general. If you reflect those accessors as attributes though, you can also avoid using the direct setter explicit syntax and let the CE handle attributeChangedCallback as that is guaranteed to happen whenever the element is initialized.

I hope this answers your question and helps you moving forward with your issue, which is again a common one, not something I can fix as even using whenDefined(localName).then(...) would likely cause more troubles than it solves so ... make your components portable and robust everywhere, don't focus on specific libraries.

The custom element has been defined already.

I dug into the uhtml code some more and it's more accurate to say this happened:

customElements.define('c-e', class extends HTMLElement {
  #ping;
  get ping() { return this.#ping }
  set ping(value) {
    console.log('setting', value);
    // never happens with element inside a template
    // happens when element is created directly
    this.#ping = value;
  }
});

const temp = document.createElement('template');
temp.innerHTML = '<c-e></c-e>';
const { content } = temp;
const cloned = content.cloneNode(true);
cloned.firstChild['ping'] = new Map([[{listName: 'coders'}, [{name: 'Jane Doe'}]]]);


const ce2 = document.createElement('c-e');
ce2.ping = new Map([[{listName: 'coders'}, [{name: 'Jamie Doe'}]]]);

Calling createElement('c-e') triggers a call to the constructor, but placing custom element tags inside a <template> does not. Now I can't compare this behavior to DocumentFragment since it doesn't have an innerHTML and working with a fragment means calling createElement one by one, which I doubt is a strategy you want to adopt for uhtml.

Still, I was curious why the template was behaving this way, so I did a quick search and found this StackOverflow answer. When I call cloned.ownerDocument I get a different Document than when I call ce2.ownerDocument. Turns out the DocumentFragment in a template is special in that it lives in a separate Document where the custom elements are not defined.1

Based on that SO answer, if I change const cloned = content.cloneNode(true); to const cloned = document.importNode(content, true);, then my constructor is called and therefore my setter function in the next line.

To fix this prototype property overriding issue, uhtml could change this line:

const root = fragment.cloneNode(true);

to an importNode call. However, that slightly changes the lifecycle behavior of your library, which is also a solution I'm not sure if you want to accept.

So for now, I'll accept your improved property-check workaround, thank you for that advice. However, I can't accept the attribute reflection solution because attributes only accept strings and I'm working with complex objects.2

Footnotes

  1. You can't even reproduce this with DocumentFragment:
    const frag = document.createDocumentFragment();
    frag.ownerDocument.createElement('c-e'); //this will still trigger a call to the constructor

  2. That is why I need to do this direct property setting strategy for my custom elements to accept a collection of collections.

v3 and lighterhtml and hyperHTML use importNode so maybe I should just use importNode too ... forgot about that detail when I rewrote the logic

to be more explicit: https://github.com/WebReflection/uhtml/blob/v3/esm/rabbit.js#L137

it used to be the case ... I need to update the uhtml/dom and the code and I will fix this.

any other particular side-effect you are thinking? I believe custom elements have connectedCallback to be sure stuff happens once connected, although it's true that as the DOM is resolved on demand maybe tere could be other gotchas around holes but then again, connectedCallback is there to help.

Only side-effect I can think of is one that effects custom elements that have render logic in their constructor: once those get called before the component is connected to the document, DOM exception errors could be thrown if you set properties such as tabIndex. I naively did this and, like you said, such logic should be in connectedCallback to being with.

from a tree prospective the DOM is safe here ... you do what you do but yes, I can't prevent people doing weird stuff ... if tabIndex is meant to be set use connectedCallback as this library and its render logic never guarantees that a node is live, so that looks like something I can't fix anyway, maybe worth a word in documentation ... would you agree?

btw, fixed on latest, let me know if you find any other weird behavior but now Custom Elements work just like they used to before.

Yep, agreed. Thanks for your help with this and applying that change 😃

No thank you for bringing this up, it was me just forgetting that tiny, yet extremely important, detail around the reason I've always used importNode instead before. I hope it's a win-win for everyone, including myself 😅