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

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
mikl
I wanted to capitalize a string, and tried using String.capitalize(). That generally works well, until you try to capitalize a word like...
New
Nvim
Anybody knows a comprehensive comparison of Django and Phoenix, thanks for the help. Where are they similar? Where do they differ the m...
New
AngeloChecked
What learn first? Rust or Elixir Hi Elixir community! I’m here because i want learn a new language. I’m a junior developer and mainly i ...
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
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
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
sergio
Kind of like when jquery came out, it was super necessary. Existing drag and drop libraries have a bunch of baggage to support old browse...
New
Markusxmr
Since Drab has been developed for a while in the open, introducing the Liveview functionality in a way it happend appears to undermine th...
New

Other popular topics Top

9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
sorentwo
Hello! tl;dr Announcing Oban, an Ecto based job processing library with a focus on reliability and historical observability. After spen...
985 43657 311
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
baxterw3b
Hi guys, i’m new in the Elixir world, and i have to say, that i love it! i’m having some problem to understand anonymous functions with ...
New
greenz1
I have a phoenix application from which a user can download multiple(5-6) files of size 1MB. I couldn’t find anything related to sending ...
New
chrismccord
Phoenix 1.4.0 released Phoenix 1.4 is out! This release ships with exciting new features, most notably with HTTP2 support, improved deve...
688 31194 112
New
grych
Hi folks, Few months ago I have announced the proof-of-concept of the library to manipulate the browsers DOM objects directly from Elixi...
639 52774 488
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
jaysoifer
Is there a way to rollback a specific migration and only that one (“skipping” all the other ones)? Would mix ecto.rollback -v 200809061...
New
dogweather
I wrote this comment on r/haskell, and it’s not popular there. :wink: But I think I’m on to something… Haskell reminds me of Java, and e...
New

We're in Beta

About us Mission Statement