Lucassifoni

Lucassifoni

Heavy DOM-querying / DOM-interaction from Liveview

Hello,
This is meant as a “food for thought” post. I’m thinking out loud and am very curious of your experiences with Liveview and very DOM-heavy use cases.

Background :
I build multi-user document editors in Elixir + Typescript + Vue and am exploring the removal of Vue from the equation (I like Vue and TS a lot, but… a mono-Elixir codebase feels better). The documents are not text as in a word processor but more like a mix of inDesign & Figma. There is sometimes free-flowing text across blocks or pages, sometimes not, and auto-layout features that respond to client-specific rules.

I have a lot of cases where I need to position things, query their size, re-calculate other sizes accordingly. In Vue-land (and certainly other frameworks), we have user or library-defined abstractions that allow to interact with the DOM. If I need to track the bounds of an element, I would use :

const bounds = useElementBounding(element);

And the underlying implementation would allow me to query that when I need to re-layout.

What I want :
I want that, at any point in time, the current document layout can be fully computed from Elixir, and that changes happen in plain Elixir modules defining the layout logic.

What I don’t need :
Real-time position/dimension tracking. This means that in my case, an user dragging an element to move, rotate or scale it, does not update the liveview at high frequency. Only its final dimension/position is of interest to the LiveView and this is perfectly handled with hooks.

Obstacles :
Querying DOM elements for their sizes, querying or setting styles, setting dimensions / offsets, via hooks, can be tedious and can lead to a lot of operation-specific hooks. A good example would be that an element got a fixed height, but its surroundings changed in a way, so it is set back to auto height, the resulting height is queried, and is saved somewhere to keep track of available space.

All of that exists in hooks, of course, but I’m trying to find a path where I massively reduce the amount of rendering logic javascript-side, not to move it from Vue to vanilla JS in Hooks.

Toy example :
I’m working directly on the assigns for the sake of brevity. The examples are very imperative and those operations would be hidden behind higher-level operations just like we do in JS.

You will see that I stumbled on a query/response implementation. Maybe if you worked with browser automation a lot it will remind you of executing JS on the current page to extract information.

  1. Get the bounding box of a DOM element, update an assign with the value
@impl true
  def handle_event("get_dimensions", _, socket) do
    {:noreply,
     TestWeb.DOMStuff.push_exec(socket, :get_bounding_client_rect, [], "#some_box", fn s, v ->
       update(s, :box_dimensions, fn _ -> v end)
     end)}
  end

  1. Get a batch of values in a single call, call a callback after the batch. The socket gets updated after all the calls came back. This can be important to avoid re-renders between calls.
@impl true
  def handle_event("get_all_dimensions", _unsigned_params, socket) do
    {:noreply,
     TestWeb.DOMStuff.batch_exec(
       socket,
       [
         {:get_bounding_client_rect, [], "#some_box",
          fn s, v ->
            update(s, :box_dimensions, fn _ -> v end)
          end},
         {:get_bounding_client_rect, [], "#some_other_box",
          fn s, v ->
            update(s, :blue_box_dimensions, fn _ -> v end)
          end}
       ],
       fn s -> update(s, :got_everything, fn _ -> true end) end
     )}
  end

  1. Sequential execution of DOM operations. This can be useful when an operation depends on another, like sizing an element after another has been rendered. I included setting and reading a style property on the blue box to give a feel of the level of control I’m thinking of. I added pauses between calls to be able to take screenshots.
@impl true
  def handle_event("sequential_example", _unsigned_params, socket) do
    {:noreply,
     TestWeb.DOMStuff.seq_exec(
       socket,
       [
         # query dimensions of the first box
         {:get_bounding_client_rect, [], "#some_box",
          fn s, v -> update(s, :box_dimensions, fn _ -> v end) end},
         # set height of the second box, computed from the width of the first
         {:"style.height", [fn s -> "#{2 * s.assigns.box_dimensions["width"]}px" end],
          "#some_other_box", fn s, _v -> s end},
         # set the background of the second box to be green
         {:"style.backgroundColor", ["green"], "#some_other_box", fn s, _v -> s end},
         # reads the background color of the second box
         {:"style.backgroundColor", [], "#some_other_box",
          fn s, v -> update(s, :second_box_bg, fn _ -> v end) end},
         # reads the bounding box of the second box
         {:get_bounding_client_rect, [], "#some_other_box",
          fn s, v -> update(s, :blue_box_dimensions, fn _ -> v end) end}
       ],
       # final callback
       fn s -> update(s, :got_everything, fn _ -> true end) end
     )}
  end


Abstraction leak / implementation :

Currently, this POC is implemented as a hook and a LiveComponent, so as user-land LiveView.

The LiveView that uses it is “polluted” by :

  • The inclusion of a live component
<.live_component module={TestWeb.DOMStuff} id="exec_renderer" execs={@__execs} />
  • A DOMStuff-specific assign on the socket
 @impl true
  def mount(_, _, socket) do
    {:ok, socket |> assign(:box_dimensions, nil) |> TestWeb.DOMStuff.with_execs() }
  end
  • Two callbacks for “exec” replies and next call execution of a sequence
  def handle_event("exec:reply", params, socket), do: {:noreply, TestWeb.DOMStuff.handle_reply(socket, params)}
  def handle_info({:schedule_batch, t, cb}, socket), do: {:noreply, TestWeb.DOMStuff.seq_exec(socket, t, cb)}

So it is super leaky and not worth keeping.

I would be very happy to be able to define higher-level DOM operations like “move this element to this other element, reset its height, see how it fits, move it back” from imperative calls and compose them in batches and sequences of batches, always able to have the actual numbers in my liveview state, without resorting to polling the DOM.

In terms of my example, it could look like this instead of manually constructing tuples (where Ops.set_height is implemented with a Ops.set_style primitive) :

  def handle_event("sequential_example", _unsigned_params, socket) do
    alias TestWeb.DOMStuff.Ops
    box_1 = "#some_box"
    box_2 = "#some_other_box"
    {:noreply,
     TestWeb.DOMStuff.seq_exec(
       socket,
       [
          Ops.ignore(box_2),
          Ops.get_bounding_client_rect(box_1, fn s, v -> update(s, :box_dimensions, fn _ -> v end) end),
          Ops.set_height(box_2, fn s -> "#{2 * s.assigns.box_dimensions["width"]}px" end),
          Ops.get_bounding_client_rect(box_2, fn s, v -> update(s, :blue_box_dimensions, fn _ -> v end) end),
          Ops.un_ignore(box_2),
       ],
       fn s -> update(s, :got_everything, fn _ -> true end) end
     )}
  end

Now the blue box is two times as high as the red box is large, but at the next render this property comes from the Elixir state and not from the DOM operation anymore.

This fictional operation would maybe be common in our application that deals with red and blue boxes, so we can extract it further. Instead of plain assign keys, we would pass state transition functions from a module dedicated to this task, but you get the idea. Compose high level DOM manipulations from small primitives to get information from the browser and use it in our state.

  @impl true
  def handle_event("sequential_example", _unsigned_params, socket) do
    {:noreply,
      from_element_double_width_set_height(socket,
        {"#some_box", :box_dimensions},
        {"#some_other_box", :blue_box_dimensions},
        fn s -> update(s, :got_everything, fn _v -> true end) end)
      }
  end

My goal with this (long) post is not to discuss this specific POC that will soon go to /dev/null but rather to ask how you handle heavy DOM-manipulation situations in Liveview : did you settle to use hooks, or maybe custom events dispatching ? Did you develop abstractions over them ? Do you use webcomponents, or live_vue / live_svelte ? Maybe you even tried some hacks with on-the-fly classes generation and JS commands ?

Have a nice day :slight_smile:

First Post!

LostKobrakai

LostKobrakai

I’m in a similar boat, where I’m working on a LV application, which involves a bunch of drag and drop. Though as you mentioned for that kind of very latency dependent interaction js simply is required. Currently this is done through a handful of hooks, which to my surprise can actually share the DnD context of the js library used between them.

I’m not sure this could ever conceivably live in elixir – and work. Layout engines for websites are a huge undertaking and even if you could build it in elixir or use some external (possibly native) dependency to do that, then there’s still the problem that that layout engine is still unlikely to match the browser the user is using. Then there’s also inherent dependencies to window/viewport size, zoom level, font rendering, font selection, … affecting layout.

Where Next?

Popular in Discussions Top

PragTob
Hello everyone, I know we had quite some threads (read through lots of them) about background job processing but it remains a hotly deba...
New
Jayshua
I recently came across the javascript library htmx. It reminded me a lot of liveview so I thought the community here might be interested....
New
Rustixir
Hi everyone, im working on find best language/framework/system for high concurrency, high performance and stable performance after wor...
New
laiboonh
Hi all, I am trying to convince my team to use liveview over the current react. What are some of the points where one should consider us...
New
WolfDan
After doing a port from a c++ library to my project in phoenix I’ve seen that I need a faster way to run this algorithm and I found this ...
New
mmport80
I have put far too much effort into Dialyzer over the last year or so - and basically - I doubt it’s worth the effort. It’s not as easy ...
New
chuck
Let me start by stating an assumption: Phoenix is a great approach to building REST APIs. There are many reasons for this, but I will ass...
New
nburkley
AWS re:Invent is on at the moment with some interesting announcements. One new feature in particular is the Lambda Runtime API for AWS La...
New
rms.mrcs
A couple of days ago I was discussing with a friend about different approaches to write microservices. He said that if he was going to w...
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New

Other popular topics Top

aadeshere1
I have a another noob question about loop. Since elixir is immutable, while loop is not directly possible. total = 10 while total != 0 ...
New
AstonJ
Posting this to see if we can make things easier for people to get into Neovim. If you use Neovim and have a favourite distro please let ...
New
WestKeys
Currently suffering from paralysis by [HTTP client] analysis. This is rather unusual in Elixirland as there tends to be consensus on the ...
New
albydarned
Hello all! I am typing this post from my new MacBook Pro with the M1 chip. I’m loving it so far, and will probably use it as my daily dr...
New
ovidiubadita
Hey all, I discovered Elixir and I love it. I always wanted to learn a functional programming and I intended to go for Haskell, but afte...
New
gausby
I asked this very same question on twitter and got some interesting feedback, but I thought it would be a good question to ask here as we...
1207 39523 209
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
saif
Hello everyone, Long time lurker first time poster here. I’ve recently begun working on Elixir full-time again! :raised_hands: It’s been...
New
shijith.k
I am trying to start a new phoenix project with elixir 1.9, but mix phx.new does not work. It says that ** (Mix) The task "phx.new" could...
New
AstonJ
Seen any cool LiveView demos, sample apps or examples? Please post them here! :003:
New

We're in Beta

About us Mission Statement