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:
-
In line 16 of file
index.tsx
you find the following:
<demo-counter label="Sheep count" onCountChange={onCountChange} />
This is 100% type safe! Especially theonCountChange
stuff is 100% type safe! -
In file
demo-counter.ts
you find the type definition and implementation of custom elementdemo-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:
-
In line 15 of file
index.tsx
you find the following line:
<demo-counter label="Sheep count" oncount-change={onCountChange} />
While thatlabel="Sheep count"
stuff is type safe, thatoncount-change=...
stuff is NOT (you do not get a compile error in this example due to the hyphen inoncount-change
but nevertheless it's not type safe (and if the event type would be onlychange
instead ofcount-change
you would indeed get a compile time error). FYI: thisdemo-counter
component supports both acount-change
event and anonCountChange
property, but in Dyo, if a property starts withon
alwaysaddEventListener
is used independent whether there's a corresponding property or not. -
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
Line 205 in 2f1f480
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
Line 206 in 2f1f480
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