Deeper integration with lit-element
Closed this issue · 6 comments
First of all, thank you for creating this library and expanding the lit-element ecosystem.
I would like to discuss a problem I found trying to integrate this library into a lit-element project and the solution that we found to said problem.
lit-grid-layout loads elements from this.items
or form this.children
Lit-Grid-Layout/src/lit-grid-layout.ts
Lines 78 to 84 in 54c5df7
Then, the elements are attached to the DOM here, which forcefully detaches them from their previous position in the DOM.
Lit-Grid-Layout/src/lit-grid-layout.ts
Lines 158 to 160 in 54c5df7
So if this.children
is used, the elements will be detached from this.children
in order to attach them under lit-grid-item.
This makes lit-element usage problematic. Let's say that the parent is in charge of rendering and passing the elements to lit-grid-layout
,
html`
<lit-grid-layout
.layout="this.layout">
${this.gridItems.map(gridItem => {
return html`<div>${gridItem.title}</div>`;
})}
</lit-grid-layout>
`;
Now when the parent re-renders, it finds that children of lit-grid-layout
are missing. So lit-html cannot patch those nodes and has to create and initialize new nodes. Not only is this more expensive, but since the new nodes replace the old nodes, the internal state of the old nodes just disappears.
To improve reactivity and integration I thought about taking another approach. The idea is to delegate rendering to lit-grid-layout
and avoid having to detach elements from a DOM position to attach them in another place.
<lit-grid-layout
.layout="${this.layout}"
.dragHandle="${dragHandle}"
.sortStyle="${sortStyle}"
.itemRenderer="${this.itemRenderer}"
@layout-changed="${this.onLayoutChanged}"
@item-changed="${this.onLayoutChanged}">
</lit-grid-layout>
The usage is similar, except that there are no this.items
or children. Instead itemRenderer
is used to create the elements from within lit-grid-layout
and thanks to the repeat
directive of lit-html re-renders will find old elements and patch them.
itemRenderer (itemKey) {
return html`<my-item
title="${itemKey}">
</my-item>`;
}
Take a look at the demo of this working branch
npm run start
and go to 127.0.0.1:8000- Click on the title/number of some items to activate them. They will turn green.
- Click on "Switch to sunny mode" or "New Layout"
Notice also that code style becomes more friendly to lit-element and lit-html.
Not having tried this approach, it does look promising. I will definitely look into this and see if this is a better approach.
I have seen other libraries do this. Not sure if they were lit or not.
In your use case, why not create the items and send them in an array to the layout?
In our case the parent dynamically creates, updates and destroys the items.
Creating the items and updating their properties using vanilla js is also a solution, as you have pointed out. But we find it cumbersome because it involves duplicating and re-implementing existing lit-html functionality. We would need to manage render
and updated
to find which elements should be updated, which should be destroyed and which elements should be created. lit-html has many optimizations and some goodies like the repeat
directive that we would need to re-implement or forgo.
In general, we find lit-html templates easier to work with.
That makes sense. I will definitely look into this. One thing to note. This project is still in the development stage. I will be making changes to the API going forward. It is being implemented at Home Assistant (Open source Home Automation Software) and after we finish a version there I will finalize V1 here. (even though I messed the version up on NPM I haven't made a real V1)
if (
!item ||
!this._layout.some(layoutItem => layoutItem.key === item.key)
) {
return nothing;
}
I do not understand this if statement here. You are checking if the item
exists and then also checking it if exists in this._layout
even though that's where item
comes from.
I like this method. But what happens if the layout array isn't updated, but the item removed. The Grid Item would still take up the space it's allotted but there would be nothing rendered in that space.
repeat(this._layout, keyFn, item => {
// Workaround: return "nothing" to remove nodes that are no longer used
// Possibly related to https://github.com/Polymer/lit-html/issues/1007
if (
!item ||
!this._layout.some(layoutItem => layoutItem.key === item.key)
) {
return nothing;
}
This workarounds a bug in the repeat directive. The repeat directive is stateful because it maintains an internal state. The render function is not called over this._layout
directly, it's called over the items of the internal state. When an item is added or modified in this._layout
the repeat directive works fine, but if an item is removed from this._layout
the repeat directive may still keep it in its internal state because of a bug.
This piece of code checks manually if the item has been removed using the "source of truth", which in this case is this._layout
. Then, if the item was removed, it returns nothing
which is a special token that forces lit-html to delete the node.
But what happens if the layout array isn't updated, but the item removed
Under this architecture nodes should never be removed manually, only layout
should be manipulated. As long as layout
is used to create, modify or remove items everything should work fine.
To delete an item, use this.
removeItem (key: string) {
this.layout = this.layout.filter(layoutItem => layoutItem.key !== key);
}
render () {
return html`
<lit-grid-layout
.layout="${this.layout}"
.itemRenderer="${this.itemRenderer}">
</lit-grid-layout>
`;
}
To create an item, use this.
addItem (newItem: LayoutItem) {
this.layout = [...this.layout, newItem];
}
render () {
return html`
<lit-grid-layout
.layout="${this.layout}"
.itemRenderer="${this.itemRenderer}">
</lit-grid-layout>
`;
}
To modify how items are rendered, itemRenderer
should be modified. Then lit-grid-layout
detects that the property itemRenderer
has changed and makes a re-render.
get itemRendererFn () {
// Generates a function with a new reference
return (item: LayoutItem) => {
switch (item.key) {
case 'foo':
return html`
<div>
<span>${this.foo}</span>
</div>
`;
case 'bar':
return html`
<div>
<span>${this.bar}</span>
</div>
`;
}
};
}
updated (changedProperties) {
if (changedProperties.has('foo') || changedProperties.has('bar')) {
// Change the function referenced by `itemRenderer` to make lit-grid-layout trigger a re-render
this.itemRenderer = itemRendererFn;
}
}
render () {
return html`
<lit-grid-layout
.layout="${this.layout}"
.itemRenderer="${this.itemRenderer}">
</lit-grid-layout>
`;
}
If only some variables change but the rendering logic is intact, the system above can become tedious. There is a simpler alternative.
// This function doesn't need change
itemRenderer = (item: LayoutItem) => {
switch (item.key) {
case 'foo':
return html`
<div>
<span>${this.foo}</span>
</div>
`;
case 'bar':
return html`
<div>
<span>${this.bar}</span>
</div>
`;
}
}
updated (changedProperties) {
if (changedProperties.has('foo') || changedProperties.has('bar')) {
// Force lit-grid-layout to re-render
this.querySelector('lit-grid-layout').requestUpdate();
}
}
render () {
return html`
<lit-grid-layout
.layout="${this.layout}"
.itemRenderer="${this.itemRenderer}">
</lit-grid-layout>
`;
}