javascripthtmldom-eventscustom-elementprototypal-inheritance

Add event handlers like "onclick" or "onchange" as public class field on a customized built-in element


I have standard handlers for the click, input and change events of my customized HtmlInputElement.

I want to define these handlers like this:

class MyInputElement extends HTMLInputElement {
    oninput = (...args) => console.log("input", ...args);
    onchange = (...args) => console.log("change", ...args);
    onclick =  (...args) => console.log("click", ...args);
}

window.customElements.define("my-input", MyInputElement , {
    extends: "input"
})

document.body.insertAdjacentHTML("afterbegin", `<input is="my-input" type="text">`)

But this does not work - none of the event handlers is called.

I experimented a bit created this version:

class MyInputElement extends HTMLInputElement {
    oninput = (...args) => console.log("input", ...args);
    onchange;
    constructor() {
        super();
        this.onchange = (...args) => console.log("change", ...args);
        this.onclick =  (...args) => console.log("click", ...args);
    }
}
window.customElements.define("my-input", MyInputElement , {
    extends: "input"
})

document.body.insertAdjacentHTML("afterbegin", `<input is="my-input" type="text">`)

oninput is the same as in the first example. onchange is defined upfront, but initialized in the constructor. onclick is only defined in the constructor.

Only the onclick handler is executed.

Why is that?

Note: I transpile my code from TypeScript with swc through rollup. I found this issue when I switched from @rollup/plugin-swc to rollup-plugin-swc3 - both basically the same configuration.


Solution

  • As it turns out, the MDN Documentation has all the infos (if you scroll down far enough).

    Because class fields are added using the [[DefineOwnProperty]] semantic (which is essentially Object.defineProperty()), field declarations in derived classes do not invoke setters in the base class. This behavior differs from using this.field = … in the constructor.

    class Base {
        _foo = null;
        get foo() {return this._foo;};
        set foo(v) {return this._foo = v;};
        trigger() {
            if(this._foo) this._foo();
            else console.log("_foo is", this._foo);
        }
        triggerByGetter() {
            if(this.foo) this.foo();
            else console.log("foo is", this.foo);
        }
    }
    
    class DerivedBad extends Base {
      foo = () => console.log("foo"); // creates a new property basically overriding get/set foo in Base
    }
    
    class DerivedGood extends Base {
      constructor() {
        super();
        this.foo = () => console.log("foo"); // this calls set foo in Base
      }
    }
    
    const good = new DerivedGood();
    good.trigger(); // console: "foo"
    good.triggerByGetter(); // console: "foo"
    
    const bad = new DerivedBad();
    bad.trigger(); // console: "foo is null" <-- this is how browsers seem to do it
    bad.triggerByGetter(); // console: "foo"
    

    It seems like the browser executes the event handlers it stores internally - it does not use the getter to retrieve it.

    And MDN also knows why different transpilers/versions/configurations create different output:

    Note: Before the class fields specification was finalized with the [[DefineOwnProperty]] semantic, most transpilers, including Babel and tsc, transformed class fields to the DerivedWithConstructor form, which has caused subtle bugs after class fields were standardized.

    In other words: babel, typescript and swc move(d) the declaration of these fields to the constructor, secretly making it work as I expected. But this is not how these fields were finally specified, so my codebase stopped working as I expected.

    So the solution is:

    class MyInputElement extends HTMLInputElement {
        constructor() {
            super();
            this.oninput = (...args) => console.log("input", ...args);
            this.onchange = (...args) => console.log("change", ...args);
            this.onclick =  (...args) => console.log("click", ...args);
        }
    }
    
    window.customElements.define("my-input", MyInputElement , {
        extends: "input"
    })
    
    document.body.insertAdjacentHTML("afterbegin", `<input is="my-input" type="text">`)