zsarnett/Lit-Grid-Layout

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

get _childrenElements(): LayoutItemElement[] {
return this.items.concat(
...Array.prototype.filter.call(this.children, (e: LayoutItemElement) =>
e.classList.contains("grid-item")
)
);
}

Then, the elements are attached to the DOM here, which forcefully detaches them from their previous position in the DOM.

>
${element}
</lit-grid-item>

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

  1. npm run start and go to 127.0.0.1:8000
  2. Click on the title/number of some items to activate them. They will turn green.
  3. 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>
  `;
}