lustre-labs / lustre

An Elm-inspired framework for building HTML templates, single page applications, and server-rendered components in Gleam!
https://hexdocs.pm/lustre
MIT License
727 stars 52 forks source link

Implement `Lazy` elements in VDOM #140

Closed ghivert closed 2 weeks ago

ghivert commented 2 weeks ago

This is a first draft for the lazy handling in the VDOM. Implementation should be fully working in the JS SPA, but should be debugged for server components.

How it works?

Lazy nodes are implemented by leveraging on the DOM. Every time a lazy node has to be created, a lazy property is set on the node, containing the params used to generate the VDOM subtree, as well as its function and the timestamp at which the subtree has been generated (more on that later). At every repaint, the lazy nodes will be compared to the real DOM node, containing the lazy property. A check is done on the parameters, by comparing them one by one. If it ends up by not needing to repaint the lazy node, then the real DOM node will be kept, and skipped in the render. If it's needed to repaint, then the lazy function will be executed, and the node replaced.

Why using a Timestamp?

The timestamp is there to distinguish an edge case in the VDOM. Let's illustrate it:

pub fn view_item(content) {
  element.fragment([
    html.div([], [html.text(content)]),
    html.div([], [html.text(content)]),
  ])
}

pub fn view() {
  html.div([], [
    element.lazy1("Bonjour", view_item),
    element.lazy1("Bonjour", view_item),
    element.lazy1("Monde!", view_item),
  ])
}

In such cases, and because there's multiple fragment, the generated HTML should be:

<div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Monde</div>
  <div>Monde</div>
</div>

When repaint the first lazy1 here, there's no way to determine how much from the previous render should be skipped. We could compare the params, but here, the 4 first nodes will have the same functions and the same params. After a rerender, we'll end up with a DOM like this:

<div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Bonjour</div>
  <div>Monde</div>
  <div>Monde</div>
</div>

Because the 4 first nodes will be considered as the first lazy (and skipped), and the second lazy will be compared to nothing, and thus new nodes will be added. To catch this bug, a timestamp is added to every nodes in a lazy rendering. This means we are now able to distinguish the nodes added in the render, and so skip the exact number of nodes from the previous fragment. The HTML is similar to:

<div>
  <div ts="1">Bonjour</div>
  <div ts="1">Bonjour</div>
  <div ts="2">Bonjour</div>
  <div ts="2">Bonjour</div>
  <div ts="3">Monde</div>
  <div ts="3">Monde</div>
</div>

So we can only compare the timestamp, and we can completely skip the params comparison!

By the way, it does not have to be a timestamp, but only a guaranteed unique integer. Time is guaranteed to be monotonically growing over time, thanks to performance.now. Random ints could be simple generated on BEAM, by using erlang:unique_integer.

ghivert commented 2 weeks ago

No use for this I think. I'll keep changes in case someone uses them one day, but I highly doubt about it. Idea with custom-components feels way better.