dyo / dyo

Dyo is a JavaScript library for building user interfaces.

Home Page:https://dyo.js.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Better support for custom elements (additional issue)

mcjazzyfunky opened this issue · comments

The only JSX based UI library that I know, where the following custom element topic is working fine, is "Crank.js", but I think this could also work with "Dyo" by only changing one line of code (at least I'm claiming it, have not really tested), plus unit tests of course.

Please check the following simple counter demo written with Crank.js:
https://codesandbox.io/s/crank-webcomponents-demo-forked-kldle?file=/src/index.tsx

The important parts are:

  1. In line 16 of file index.tsx you find the following:
    <demo-counter label="Sheep count" onCountChange={onCountChange} />
    This is 100% type safe! Especially the onCountChange stuff is 100% type safe!

  2. In file demo-counter.ts you find the type definition and implementation of custom element demo-counter. This type definition there is completely framework agnostic!
    For the implementation of that custom element an experimental webcomponent library is used, but this is not important here - please just ignore it.

Now please check the Dyo version (is working properly):
https://codesandbox.io/s/dyo-webcomponents-demo-forked-u7ek6?file=/src/index.tsx

The important parts are:

  1. In line 15 of file index.tsx you find the following line:
    <demo-counter label="Sheep count" oncount-change={onCountChange} />
    While that label="Sheep count" stuff is type safe, that oncount-change=... stuff is NOT (you do not get a compile error in this example due to the hyphen in oncount-change but nevertheless it's not type safe (and if the event type would be only change instead of count-change you would indeed get a compile time error). FYI: this demo-counter component supports both a count-change event and an onCountChange property, but in Dyo, if a property starts with on always addEventListener is used independent whether there's a corresponding property or not.

  2. File demo-counter.ts is exactly the same as in the Crank version - still the type definition for that custom element is framework agnostic.

This is how this issue could be fixed in Dyo (at least I think so):
For each event normal DOM elements have also a corresponding "onevent" property. So you can either use myButton.addEventListener('change', ...) or myButton.onchange = ....
A well implemented custom element should do the same IMHO with each supported event.

If in the following line

if (valid(name)) {

if (valid(name)) was replaced by if (valid(name) && !(name in instance)) then in the Dyo demo the line <demo-counter label="Sheep count" oncount-change={onCountChange} /> could be replaced with <demo-counter label="Sheep count" onCountChange={onCountChange} /> and everything would be nice and type safe also in Dyo.

@thysultan What do you think?

It will mean that not only onCountChange but also "onclick"(etc) will not be recognized as an event by Dyo as it will just set it as a node.property = value setter, and since this also effects normal events like onclick it would break the current contract Dyo has with events like the arguments passed to event callbacks, how setState works in events and etc...

Another idea would be to change this

return event(name.substr(2).toLowerCase(), value, instance, current, current.state ? current.state : current.state = {})
to

return event(name in instance ? name.substr(2) : name.substr(2).toLowerCase(),

But that might not work for your case if you have the event as only a property and your also surface this issue #132 (comment) mentioned before.

I really cannot believe that it is technically that complicated for a VDOM/JSX based UI library to support properly (whatever that means) implemented custom elements directly out-of-the box in a also out-of-the-box 100% type safe way. That would be such a great feature.

Frankly, I have zero knowledge how a target agnostic interface for building up hierarchical UI element/component trees should look like.
But I'm not sure whether it's really a good idea that that Interface.js in Dyo is so DOM driven.
I guess, if you do it that way, one advantage is that the dyo.render function can be used independent of the target and you do not have to use target specific render functions in different packages (=> import { render } from 'dyo/dom') and also you do not even have to write a special renderer for DOM as it already works out of the box because DOM elements already implement the interface described in Interface.js, but is that really such a big advantage?

Wouldn't it be much more flexible and easier to implement adapters for non-DOM targets if that Interface.js did not describe the methods of the UI elements within the UI tree, but instead the methods of the renderer (which are basically the same methods but not attached to the elements - which now could be of an opaque type - but to the renderer).
Instead of instance.removeChild(...) it would be renderer.removeChild(instance, ...).

Then you could get rid of that very DOM specific addEventListener, removeEventListener (seems to be missing at the top of Interface.js, I think), setAttribute and removeAttribute methods and replace them with a a more general renderer method setProperty (if you like, add also a removeProperty method) or whatever. Let's not forget that JSX does not know anything about attributes and events but only about props and children.

Of course, you would have to implement a little renderer for DOM, but at least this would make things more flexible.

Anyway, my bad, I just thought that little one line change described above would do the trick.
Obviously, I was wrong. So I'll close this issue now. Many thanks for the discussion and information.

Dyo currently ships with a default DOM/NOOP/SERVER renderer out of the box from import {render} from 'dyo' I'd like to preserve this. In fact it moved away from import {render} from 'dyo/server' which was the case in a previous Dyo version.

If i'm not mistaken TypeScript can type what you are after using JSX intrinsics.

If i'm not mistaken TypeScript can type what you are after using JSX intrinsics.

In my demo above this was already done in a framework agnostic manner.

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'demo-counter': PropsOf<typeof DemoCounter>
    }
  }
}

But this will neither work with Dyo, nor with Preact. I would have to write a special custom element type definition by hand for Dyo and Preact (as the prop names for events with kebab-case event names are different), which of course is not an option at all.
Another option would be always to use lower case event names without hyphen for my personal custom elmenets, but I personally do not like this too much.
What really puzzles me is, whether really nobody uses the combo Preact+Typescript+Custom elements or at least uses this combo and has the above mentioned problems ... really strange ....

If Dyo uses onCountChange as is(no lowercase) with addEventListener will your use case work? If so instead of doing this:

The way preact supports those events is to assume any function value that starts with on but does not have a corresponding name in Node reference will be applied with out lowercasing the name.

We could do the opposite, i.e if the property exits don't lowercase, which might not effect non DOM renderers, or maybe i misunderstood this from the get go?

Thanks, but that would not really solve the issue.

This issue is generally about the question: "How do I have to design custom elements to work fine out of the box (even regarding type safety) with sophisticated JSX based UI libraries (at least if they are smart enough) without using any dirty workarounds?" And the main problem is that in DOM you do not only have properties (as with JSX) but also attributes and events. While there seems to be a common agreement about the naming convention of properties (=> camelCase), there is no common convention for naming attributes and events (I personally prefer kebab-case for both of them, but others are of a different opinion, which is also fine of course).

No problems with lit-html as you always have to indicate explicitly whether it's a property, an attribute or an event:

html`
  <some-component
    some-attribute="some-string"
    .someProp=${somePropValue}
    @some-event=${onSomeEvent}
  >
  </some-component>
`

As JSX does not know anything about attributes and events, but only about properties and children, you have to use heuristic approches to distinguish between properties, attributes and events.

Regarding attributes the rule is simple: "For each attribute of your custom element also provide a corresponding property (like done in DOM for the built-in elements) and it will work fine with JSX if your UI library is smart enough", which is working fine with the latest Dyo version, Preact and Crank - not working with React, but that's more like a React specific problem.

Now it would be nice to also have a similar simple, workaround-free solution for event handling.
One rule could be: "For each event of your custom element also provide a corresponding property (like done in DOM for the built-in elements) and it will work fine with JSX if your UI library is smart enough". This currently only works with Crank (neither with Dyo nor with Preact).

I cannot open a ticket for a third-party custom elements suite project, saying "Could you please implement the following ugly workaround for each of your custom elements to work with UI library XYZ?". But I could ask: "Would it be possible that the elements of your UI component suite implement the same 'onevent' contract for each event as DOM is doing it with its built-in elements by providing a corresponding property for each supported event, so it would work out of the box with JSX based UI libraries like Preact and Dyo etc.?"

If a custom element satisfies those two rules about attributes and events, the JSX based UI libraries would not even have to care about the naming convention of the attributes and events any longer as they would never call setAttribute or addEventListener on these custom elements, so the actual names of the attributes and and events would not really be important here.

But the custom element does not satisfy the rule that normal DOM elements do — That is that however you add an event via node.addEventListener or node.onevent property assignment, it will always correctly dispatch the event when an event of the same name is caught by it. So assuming the lack of support is specifically with addEventListener registered events, that would mean that custom events on custom elements seem to have a short fall with how normal Elements work in that they have concurrent support for both.

Not really sure whether I've understood you correctly. But I am NOT saying that with that change mentioned above magically ALL custom elements will work perfectly fine with Dyo out of the box. I just said that a nice subset of custom elements could work fine (those who satisfy the "onevent" contract properly), which is much better than now.

For example with this experimental web component library that I am using in my demos above the line

   onCountChange: prop.evt<CountChangeEvent>()

will make sure that (by convention) both

myCounterElem.onCountChange((ev: CountChangeEvent) => {...})

and

myCounterElem.addEventListener('count-change', (ev: CountChangeEvent) => {...})

will work out of the box.
So, if Prect and Dyo would support that "onevent" thingy all custom element implemented with this experimental web component library worked (out of the box) perfectly fine with Preact and Dyo, what would be a big win.
I guess with some other web component libraries it will need some few more lines of code to achive the same, but at least it will be possible.

Actually, I do not want to waste too much of your time - if it's currently not possible then it's not possible ... but it's really a shame that you cannot use
<some-component onSomeEvent={...}/> (where the event type is some-event => kebab case, and the property name is 'onSomeEvent' => camelCase)
in Preact and Dyo, but have to use
<some-component onsome-event={....}> instead.

But wouldn't onsome-event also not work, in-fact i think the custom event oncustom would also not work with the way you've setup the custom element being property driven and not listenable through node.addEventListener

But wouldn't onsome-event also not work

Actually, that pattern is used in my Dyo demo above and woking (check the JS console output):

  <demo-counter label="Sheep count" oncount-change={onCountChange} />

i think the custom event oncustom would also not work with the way you've setup the custom element being property driven and not listenable through node.addEventListener

Like said this line here

 onCountChange: prop.evt<CountChangeEvent>()

will make sure that "setting the onevent property" AND addEventListener both will work (if addEventListener did not work, the Dyo demo would also not work and the JS output would be missing). This very opinionated toy library just derives event names from the property names: If the property name matches /^on[A-Z]/ the property is considered an event property, it takes the property name removes the leading 'on', will convert the result to kebab-case, and voilà it has the corresponding event name, which then can be used with addEventHandler (as the support for this custom event will automatically be added by this web component library).

But isn't that only because of the onClick handle on the custom event and the effect that calls the onCountChange callback, and more so the later?

Regardless if the current oncount-xxx works then i don't see how my suggestion(#134 (comment)) would not make onCountChange work.

Regardless if the current oncount-xxx works then i don't see how my suggestion(# 134 (comment)) would not make onCountChange work.

oncount-change={...} only works because the event type name is exctly count-change.
Most third-party custom elements use lowercase or kebab-case event names (and camelCase for properties).
I guess, those third-party custom elements would not work

If Dyo uses onCountChange as is(no lowercase) with addEventListener [...]

because that's not exactly the event type name.
That's why I said, I don't think that your suggestion would work, at least not with kebab-case event names.

Okay, but to come to an end: It seems that there's currently no good solution for this issue.
Maybe I'll open a similar ticket in the Preact issue tracker soon, maybe they will have some additional ideas. I'll keep you informed.
Again, many thanks for your time.

PS:

BTW: Actually, I've made a mistake in the demo: The interface CountChangeEvent should be somehow extending CustomEvent. Later I should use props.onCountChange(new CustomEvent('count-change', ...)), but I don't think that this is the only reason for the confusion.

Also just to make clear (in case this may be a reason for confusion): This internal props.onCountChange value is NEVER the same as the custom element's outer property of the same name. It's just "some" function that handles all cases where somone has registered for the count-change event by using myCounter.addEventListener('count-change', ...) or/and setting myCounter.onCountChange directly.

But I've just used that toy library because it's easier for me to write a short demo. This is really, really not important here.

Then what was the issue in the first place? Was it just about the on prefix heuristic for events?

Was it just about the on prefix heuristic for events?

I just claim that it has a lot of advantages if the heuristic is something like the following: First check whether there's an element property with the exact same name as the JSX prop and if yes then directly set that property and do not use addEventListener even if the JSX prop name starts with 'on', otherwise check whether the JSX prop name starts with on and if yes use addEventListener with lowercased event name as currently done if you like, otherwise use setAttribute.

Moreover: I would consider using <button onclick={...}/> with Dyo a bug (even if it's working).
Also the fact that <button onclick={...}/> does NOT throw a TS compile error is just for the fact that the types in Dyo's index.d.ts may currently be too loose (Preact and React typings are stricter here).

I see, in that case i think that takes back to the same breaking issue as #132 (comment)

@thysultan This is just for your information: I've promised somewhere above to inform you when I'll open a similar ticket for Preact. Please see preactjs/preact#2761