Closed amarajs closed 5 years ago
Hi @amarajs! It is great to hear, that we have heavy users of the hybrids out there :)
I have to start with a little offtopic. A few days ago I had a presentation on the ConFrontJS conference in Warsaw about architecture decisions behind the library. When I was preparing I realized that the current state of the documentation actually doesn't explain very well those things, so I have to update it as fast as possible (The talk will be available on YouTube soon, for now, you can see the slides here).
I think your question is related to some of those concepts. All of what I achieved was possible only when all of those concepts were connected to each other. I mean property descriptors instead of classes and cache mechanism to avoid complex lifecycles.
The laziness is built-in in the nature of the hybrids. The component cycle is actually different from general solutions. In this model, each one of the property takes responsibility for itself. It means, that render factory is a reason why other properties should be called and recalculated - not opposite.
This is the slide showing a component simplified lifecycle. It shows that model is simple - if your element renders something (and usually it does) this is a starting point, which should take other properties values as an input and in the result render current state to DOM. The side effect (in your example some method), should be attached to other elements by events or other triggers, but in the context of DOM. Then you have no problem with when is something rendered because those side effects are there at the same time when your element renders.
Ok, let's back to your original question. Firstly, I had an idea to add also construct()
or similar function to property definition. However, when I've started working on factories I realized, that is not required, as what you could do in constructor
, you can do in connect
. The only difference is, that connect
can be called multiple times (for example, when you move an element from one place to another). As I wanted to create a library, which does not have to explain the way you have two similar options and when using what, I chose to not implement construct()
.
Even though it would be there, I still think that render should be done in the next animation frame, and postpone if it breaks 60 FPS.
The most important thing here is a mind switch, which is required when working with the hybrids. What do I mean?
Your example with creating a custom dialog with the native one is an actually great example. The problem is with the native element, not with your... Why anyone would like to show a dialog (by show()
or showModal()
) if the element is not yet connected to the DOM? Why there is also open
attribute? Is there any difference between using attribute and methods? (Yes, there is... ☹️).
You can fix this (but not covering all possibilities):
define('my-custom-dialog', {
open: false,
render: ({ open }) => html`<dialog open="${open}">hello</dialog>`,
});
const el = document.createElement('my-custom-dialog');
el.open = true; // not throws - just set "open" property
document.body.appendChild(el); // Now dialog is rendered within some next RAF with open set to "true"
Do you see a difference here in the design? Properties are inputs, render is a course, which takes current values and uses it to render the template. Using methods is strictly imperative - they do some action. Using properties is declarative - you set new state of the component - and it reacts to those changes by updating its template.
However, sometimes, it is possible, that you would like to use external element, which uses an imperative approach. What then? If your <custom-dialog>
would be implemented as you showed, you can still use it inside of another custom element implemented with hybrids:
function openModal(host) {
host.shadowRoot. querySelector('custom-dialog').open();
}
const MyView = {
render: () => html`
<button onclick="${openModal}">Open modal</button>
<custom-dialog></custom-dialog>
`,
};
However, I would strongly recommend to use declarative approach, and even <custom-dialog>
internally can use showModal()
method, it should have property show
or open
, which when changed (for example in setter) calls internal native dialog and try to call its method.
All of this would work, as a side effect - clicking on a button - is outside of the scope of rendering (button is visible when the render process is finished). Theoretically, it is possible that custom dialog it is still not rendered and a user would click a button, but it should be done in one frame (user would have 16ms for that :P).
As you can see, the hybrids favors declarative way - maybe sometimes it is not possible to implement all things in that approach - then you can use other tools (as custom elements are independent). But, if it is carefully designed I think almost all cases can be resolved.
Congratulations on your presentation -- the slides look great!
Treating a render function as the output of state and property changes makes sense -- Elm and CycleJS were early proponents, and it looks as though the Hooks proposal for React moves a bit more in that direction, too, eliminating classes and lifecycle methods in favor of a more functional approach. I'm glad to see most of the libraries moving in this direction.
But I still don't understand why that means we shouldn't call render() at the end of the constructor to create the component's initial shadow DOM. All the properties that might be inputs to render will have been created at that point, so what is the reason to not make the initial render call (synchronously, not within a rAF callback)?
For example, the whole idea behind async render in React (in React Fiber engine) is to make it eventually done. It means, that if a component is created is not yet known if it will be rendered at all (but in React is more about lifecycle, that might be not called).
One of the reasons behind this pattern is to create not blocking UI by splitting JavaScript execution. The idea is to render those components, which can be painted in the next animation frame, and just them.
That's why we don't want to render at the end of the constructor call. Firstly, as the hybrids tries to simplify API, a constructor is omitted (all of what we can call in the constructor, we can call in connectedCallback
). Secondly, render factory implements similar idea to split update to not block UI, and only update elements in 16ms window. This is the reason to render even at first in the next animation frame.
Moreover, "initial render" is not only when you initialize your "application". Everytime when by the change of the state of the element, new ones can be created. What about them then? For new elements, it is its the initial phase.
With all of what I wrote in the last comment, I think it should be obvious, that if we want to achieve that goal, it requires lazy approach and call render only when it is needed.
I read again my comment and I think you may still do not understand what I mean 😆
The most important thing with making render lazy is that if it would be synchronous (wherever it would be), it can make UI blocked. You can have app, which dynamically adds a lot of new custom elements to the DOM, and if the library would render initial structure in sync, there would be a problem with too much work to be done.
I wasn't talking about making all rendering synchronous -- just the initial shadow root and DOM creation. After that, feel free to defer rendering in favor of 60fps. I simply can't find any other examples of libraries creating shadow roots asynchronously -- it's always done in the constructor.
This issue prevents me from consuming Hybrids components in all the browsers our existing site needs to support. The async initial rendering is causing too many timing issues given all the other activity occurring in our site and trying to integrate with other frameworks' DOM creation and change detection strategies.
In any case, React doesn't output web components. What I build in React won't ever be consumed outside of React, so it's not really a fair comparison. The promise of web components is that they can be consumed by pretty much any framework that understands DOM. But with async shadow root creation, that's not always going to be true.
I'm sorry -- I really like the ideas behind this project, but I will have to recommend to my company that we not use Hybrids and instead either write vanilla web components or seek out another library that will create the shadow root and initial DOM in the element's constructor.
Thanks for your time.
Ok, these are my few final thoughts:
Shadow Root is created synchronously in the connectedCallback()
of Custom Elements API. Only the content of shadowRoot
is qued within next RAF (or the some of the next frames). You wrote: just the initial shadow root and DOM creation. Initial DOM creation can be costly. It is one of the important things that I wanted to highlight. The initial DOM creation does not mean, that it would be done only when your app starts. Every single element is independent, and its initial render is when it is created and connected to the DOM.
The hybrids library is very flexible. You don't have to use built-in render factory, or you can use it where it fits you. You can define your render
property whatever you want (you can always use property descriptor). If you have a requirement for synchronous render, you can try to create your custom factory like this (with fixed support for shadowRoot
):
function syncRender(fn) {
return {
get: host => fn(host, host.shadowRoot),
connect: (host, key) => {
if (!host.shadowRoot) {
host.attachShadow({ mode: 'open' });
}
const cb = (event) => {
if (event.target === host) {
host[key]; // access to property calls `fn` if needed (if cache invalidated)
}
};
host.addEventListener('@invalidate', cb);
return () => host.removeEventListener('@invalidate', cb);
},
};
}
export const MyElement = {
value: 0,
render: syncRender(({ value }) => html`
<div>${value}</div>
`),
};
The whole idea of the custom element is to hide internal structure. I have to say it - if you need to access the internal structure of custom element shadowRoot
- you doing it wrong. The custom element should be for you a black box with inputs - properties and eventually methods, and with outputs - custom events, that you may listen. The custom element internal structure is not a public API of the element. It may change in anytime. Think about built-in elements. They use Shadow DOM as well. For example, <input>
element is a <div contenteditable>
. However, you are only allowed to see an <input>
element. For custom elements using close
mode is not recommended with one main reason - third-party tools, like screen readers, need that access to understand what your custom element is. It is not for the users of those elements to work with them. If you take this argument, It might be more clear to you, why the time, when elements are rendered don't have to be sync.
You should always choose for your company solutions that are best for the productivity, maintenance and developers happiness. If the hybrids does not fit in your needs, you are welcome to use another solution. Also, my goal is not to try to force you to use this library, or to think that it is the best solution for all problems with rendering UI. However, I would not recommend using vanilla solution for more complex web components - it might require a lot of code to write, and it can be hard to maintain.
I now understand what @amarajs means with "async shadowRoot" I'm using materialize css, and to use materialize js components, I need to initialize it, but when I try to access the shadowRoot, i get a null value. I had to set a interval to make it work
const el = document.createElement('x-ongs-list');
appDiv.appendChild(el);
const interval = setInterval(() => {
const shadowRootElement = document.querySelector('x-ongs-list');
if (shadowRootElement.shadowRoot) {
const collapsibleElements = shadowRootElement.shadowRoot.querySelectorAll('.collapsible');
const instances = M.Collapsible.init(collapsibleElements);
clearInterval(interval);
}
}, 100);
@DavideCarvalho Can you create a code example with materialize js and put it on StackBlitz or similar website?
I've been thinking a few days about the problem. The most important conclusion here is that the hybrids is not a holy grail, and I would not add in my opinion anty-patterns to the library only to support an imperative way of coding, or to create wrappers for already created solutions (like the last example with accordion).
@DavideCarvalho, for you I recommend looking at my last message. You can use my snippet of synchronous render factory and avoid setInterval
completely.
The only way to support your problems with async render would be an event triggered by the render factory when DOM is updated. However, it could lead to an endless loop, as then it would be possible to change properties related to the render
method, which would cause re-render.
As I wrote before, the library is super flexible, so you are not forced to use render factory at all. You can write anything that updates DOM if you like.
With my last comment, I think I wrote everything I could for this subject, so I close this issue. If you have new thoughts about how to make it simpler for other users of the hybrids, you are welcome to re-open.
I agree with you, but maybe you should incorporate those "add-ons" - like this sync shadowRoot - inside the library, so if someone needs it, they only need to import and encapsulate the component or the property with it.
Doing some research, SkateJS also has a asynchronous render, but it exposes a method called .ready
so we can know when the component is fully mounted. Maybe this can be a good approach
https://github.com/skatejs/skatejs/tree/1.0.0-beta.2#ready-element-callback
SkateJS has a different lifecycle model, it only re-render for you if you use state()
function from the library (in 1.0.0-beta.2 version from your link). As I wrote in my last comment, rendered or whatever we would call that event can lead to an endless loop because hybrids manage state changes for you. It could happen if you change without any condition related properties in that callback:
render called (for the first time) -> rendered event callback changes properties -> render called (next time) -> rendered event callback changes properties ...
I still will argue, that those callbacks are not required. You can create web components without them. It might require a different approach and mind shift, but still, it is not something that should be in the core of the library.
Also, there is nothing that stops you from creating your render
factory and use it :)
Hey guys, I've been using Hybrids pretty extensively for a couple months and had a question about one of the design decisions / gotchas that's bitten me a number of times. In the past, there has typically been a good reason for the choices made in Hybrids, so I imagine that may be the case here, too -- just wondering what that reason might be...
Basically, why do you wait to attach the shadowRoot until the initial render? Most custom element sample code and existing libraries attach the shadowRoot and create the shadow DOM within the element's constructor, so it is available from that component's properties and methods immediately.
In addition to the shadow DOM not being available until the component is connected, because Hybrids optimizes rendering to 60fps by potentially deferring tasks to subsequent animation frames, the shadow DOM's existence immediately following connection is non-deterministic. It may be available in the next requestAnimationFrame, or -- on slow browsers like IE11 -- it may not be available until much later.
This can cause timing issues where methods and properties may try to access shadow DOM that is not yet available. Should attempts to access non-existent shadow DOM throw an exception? Should it queue all requests and invoke them when rendering finally occurs? Should it defer the first request until rendering? The most recent request?
We can avoid these questions entirely by performing an initial render in the constructor and rendering any updates within the rAF queue. Since properties are already set in the constructor, I can't see any immediate logistical reason why the initial render needs to be delayed -- but there's probably something I'm missing.
Here's an example:
Is there any chance you can move the initial render to the constructor? If not, what's the best way to work around the non-deterministic shadow DOM issue?
As always, thanks for a great library!