lustre-labs / lustre

A Gleam web framework for building HTML templates, single page applications, and real-time server components.
https://hexdocs.pm/lustre
MIT License
948 stars 66 forks source link

Fragment, ordering & custom components #146

Closed ghivert closed 3 months ago

ghivert commented 3 months ago

Hi!

I'm still continuing working with fragments and laziness on lustre, and I'd like to open a few points that I think may be concerning for the usage of the lustre framework, when working with fragments.

Dynamic fragment and custom elements

Currently, the VDOM fragment algorithm is efficient, but does not take into account the structure of the tree itself, because it flattens the children of the fragment before handling them. The VDOM has no way to detect if the node is included in a fragment, or if it's written directly in the children of the parent. This lead to an under-performant behaviour, and to an error with custom components. Let's see in details why:

Considering those two files:

// main.gleam
import gleam/int
import gleam/io
import gleam/list
import lustre
import lustre/element
import lustre/element/html
import lustre/event

@external(javascript, "./ffi.mjs", "run")
fn run() -> Nil

pub fn main() {
  run()

  let app = lustre.simple(init, update, view)
  let assert Ok(_) = lustre.start(app, "#app", Nil)

  Nil
}

fn init(_flags) {
  0
}

type Msg {
  Incr
}

fn update(model, msg) {
  case msg {
    Incr -> { model + 1 } % 10
  }
}

fn view(model) {
  html.div([], [
    element.fragment(
      list.range(0, model)
        |> list.index_map(fn(_, index) {
          html.div([], [html.text(int.to_string(index))])
        }),
    ),
    html.button([event.on_click(Incr)], [html.text(" + ")]),
    element.element("custom-counter", [], []),
  ])
}
// ffi.mjs
class Counter extends HTMLElement {
  state;

  constructor() {
    super();
    this.state = 0;
  }

  connectedCallback() {
    console.log("Connected");
    this.render();
  }

  disconnectedCallback() {
    console.log("Disconnected");
  }

  render() {
    for (const child of this.children) {
      console.log(this.children);
      this.removeChild(child);
    }
    const increment = document.createElement("button");
    increment.addEventListener("click", () => {
      this.state += 1;
      this.render();
    });
    const decrement = document.createElement("button");
    decrement.addEventListener("click", () => {
      this.state += 1;
      this.render();
    });
    const count = document.createTextNode(`${this.state}`);
    this.replaceChildren(increment, count, decrement);
  }
}

export const run = () => {
  customElements.define("custom-counter", Counter);
};

The idea is to create a custom element, and to make sure it does not recreated at every repaint. Right now, every time we add or remove a new child to the fragment, the component gets recreated.

At first paint, we got this DOM

<div>
  <div>0</div>
  <button> + </button>
  <custom-counter></custom-counter>
</div>

When pushing the + button, we got this DOM:

<div>
  <div>0</div>
  <div>1</div>
  <button> + </button>
  <custom-counter></custom-counter>
</div>

When the algorithm runs, it will morph all the existing nodes into the first nodes, and create a new custom-counter at the end of the child. In the reverse way, if we remove a child, all nodes will be morphed to the new state, and the last custom-counter will be removed. While it's OK to do it for most nodes, if lustre want to have custom components as solution for laziness or other stuff, this problem can arise quickly, also because React does handle such a case correctly: React keeps the Fragment in the tree, and just flattens the resulting DOM.

We can observe this behaviour here (we'd like to make sure the custom element get ever mounted once):

Capture d’écran 2024-06-12 à 10 37 17

Performance & ordering of following fragments

Let's consider another view function, with a starting state at 5:

fn view(model) {
  html.div([], [
    element.fragment(
      list.range(0, model)
      |> list.index_map(fn(_, index) {
        html.div([], [html.text(int.to_string(index))])
      }),
    ),
    element.fragment(
      list.range(0, model)
      |> list.index_map(fn(_, index) {
        html.div([], [html.text(int.to_string(index))])
      }),
    ),
    element.fragment(
      list.range(0, model)
      |> list.index_map(fn(_, index) {
        html.div([], [html.text(int.to_string(index))])
      }),
    ),
    html.button([event.on_click(Decr)], [html.text(" - ")]),
  ])
}

At first paint the DOM will be:

<div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>4</div>
  <button> - </button>
</div>

When removing a child (clicking on -), we expect the DOM to be

<div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
  <button> - </button>
</div>

The patch applied by the VDOM is:

<div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
- <div>4</div>
+ <div>0</div>
- <div>0</div>
+ <div>1</div>
- <div>1</div>
+ <div>2</div>
- <div>2</div>
+ <div>3</div>
- <div>3</div>
+ <div>0</div>
- <div>4</div>
+ <div>1</div>
- <div>0</div>
+ <div>2</div>
- <div>1</div>
+ <div>3</div>
- <div>2</div>
- <div>3</div>
- <div>4</div>
  <button> - </button>
</div>

Which will contain 11 modifications, while if the structure of the Fragment is kept, we could have a different patch:

<div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
- <div>4</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
- <div>4</div>
  <div>0</div>
  <div>1</div>
  <div>2</div>
  <div>3</div>
- <div>4</div>
  <button> - </button>
</div>

This would contain only 3 modifications (the modifications included in the Fragment), and this would make sure to keep the existing nodes in place (avoiding mounting/unmounting of components).


I know we cannot keep the structure perfectly in some update, but I think improving fragment management could be a great addition to lustre, and if we want to support custom components with states, we should try to avoid a maximum of creation/destruction of those components in my opinion.

hayleigh-dot-dev commented 3 months ago

This is what keyed elements are for, and has nothing to do with fragments and everything to do with rendering lists. Please read the docs for element.keyed.

This is why react requires you to always provide a key when rendering elements from an array, for example.