LiveView sending more data than expected

I’ve been trying to troubleshoot this issue, but I think I might have a fundamental misunderstanding of what is expected and maybe what I’m seeing is what is expected.

I assign a list of rows where each row is made up of a list of maps that represent the individual cells. Everything looks good in the UI, and updates as expected, but when I enableDebug on the liveSocket it looks like the entire interior of the table is being sent down the websocket when any change is made, not just the changed cell. It does properly breakup the static content with the dynamic content, but sends redundant data.

The reason I think the issue is my understanding is because I inspected the code and websocket communication on the LiveDashboard system on the Sockets page and basically see the same result as with my code. There is a table, and everytime the 15s update goes out, it looks like it is sending all the cells of the table, even the unchanged cells.

Here is the shortest version of my code. I dynamically adjust the number of columns and rows based on the screen size by using a phx-hook, here is the HTML:
<div id="page_resize" phx-hook="PageResize"></div>
here is the hook in app.js:

Hooks.PageResize = {
  mounted() {
    window.addEventListener("resize", (e) => {
      this.pushEvent("page-resize", {
        screenWidth: window.innerWidth,
        screenHeight: window.innerHeight,
      });
    });
  },
  destroyed() {
    //   TODO: create this.pageResize in mounted so it can be destroyed
    // window.removeEventListener("resize", this.pageResize);
  },
};

and that event is handled by:

  def handle_event(
        "page-resize",
        %{"screenWidth" => screenWidth, "screenHeight" => screenHeight},
        socket
      ) do
    socket =
      assign(
        socket,
        screenWidth: screenWidth,
        screenHeight: screenHeight,
        rows: Table.Cell.list_rows(screenWidth, screenHeight)
      )

    {:noreply, socket}
  end

which populates the “rows” with this list_rows function:

  def list_rows(width, height) do
    rows = Integer.floor_div(height - 2, 32)
    cols = Integer.floor_div(width - 2, 132)

    for r <- 1..rows do
      for c <- 1..cols do
        %{
          row: r,
          column: c,
          string: "R#{r}C#{c}"
        }
      end
    end
  end

and finally in the render I have this code:

    <table>
    <%= for row <- @rows do %>
      <tr>
      <%= for cell <- row do %>
        <td class="bg-gray-50 border-gray-200 border-2">
          <div
          tabindex="0"
          class="font-mono w-32 overflow-hidden whitespace-nowrap pl-1 py-0.5 text-gray-700"
          >
            <%= cell.string %>
          </div>
        </td>
      <% end %>
      </tr>
    <% end %>
    </table>

When the window size changes enough to add a new row or column (or remove one), I would expect that only the new rows would come through the diff. But instead every cell comes through on the websocket update. Not just the innerText (like “R1C1”) but also the lengthy TD & DIV definition with all of the same classes and such.

Am I doing something wrong, or is this what I should expect?

I will say that the updates are super fast, but I’m doing local development and I’m not quite sure what it will be like for people with higher latency and lower bandwidth. So if I am doing something wrong to cause it to send so much data, I want to resolve it.

Elixir 1.11.2, Phoenix 1.5.7, OTP 23, LiveView 0.15.0

1 Like

Have a look at https://hexdocs.pm/phoenix_live_view/dom-patching.html

Thank you, I have now read that and implemented some changes and new tests, but doesn’t seem to have resolved my issue/understanding.

It appears to me that adding phx-update to my code changes the way patching is done, but not the way diffing is done. Regardless of what mode I put phx-update the same diffs come through the websocket, but the way the table is modified is different.

It did force me to improve my data structure and the html code, so that’s good.

Say I have a table with 2 rows and 2 columns that are built in a nested for loop based on a data structure, and I modify that data structure to now have 3 rows and 2 columns. I would expect the rendered diff to only send the new row and its two cells through the socket. But what is happening is that all 3 rows are coming through that diff even though nothing changed in the first 2 rows.

I’m left thinking that either:

  • my expectation is wrong and that all 3 rows should be sent in the diff even though the first two rows didn’t change
  • the way I’m building the HTML in 2 nested for loops is incorrect and tripping the diff system
  • or the way I’m assigning the updated list of rows to the socket is incorrect and making the diff think that all the rows changed even when they didn’t

This is similar to before, but slightly updated. Here is the code that updates the socket:

socket =
  assign(
    socket,
    screenWidth: screenWidth,
    screenHeight: screenHeight,
    rows: Table.Cell.list_rows(screenWidth, screenHeight)
  )

this is the function used to populate rows in the assign of the socket:

def list_rows(width, height) do
  rows = Integer.floor_div(height - 2, 32)
  cols = Integer.floor_div(width - 2, 132)

  for row <- 1..rows do
    %{
      row: row,
      cells:
        for column <- 1..cols do
          %{
            row: row,
            column: column,
            string: "R#{row}C#{column}"
          }
        end
    }
  end
end

and this is the leex:

<table id="table">
<tbody id="tbody" phx-update="replace">
<%= for row <- @rows do %>
  <tr id="tr-<%= row.row %>">
  <%= for cell <- row.cells do %>
    <td class="bg-gray-50 border-gray-200 border-2">
      <div
      tabindex="0"
      class="font-mono w-32 overflow-hidden whitespace-nowrap pl-1 py-0.5 text-gray-700"
      >
        <%= cell.string %>
      </div>
    </td>
  <% end %>
  </tr>
<% end %>
</tbody>
</table>

I had a very similar problem, a grid of cells that would represent a battleship game using LiveView. As you can imagine, if you were to click a cell, it would need to update the icon based on whether the selection was a hit or miss on the other player’s side. When I did it naively, I found that a single icon change would send the entire “row”, which confused me as I was under the impression that it would have just done the cell itself.

However, when you think about how diff tracking works, LiveView has to detect the changes that were made from the changed set of assigns. It has to evaluate those changes and send them down, which might be the reason why it’s sending so much.

I was told that one of the ways I could optimise this was to use LiveComponents and move the state tracking away from the LiveView itself. Initially I had something like this:

  1. Mount the live view entire grid with the grid state as the live view assign
  2. Render the grid as is
  3. User would click on the cell, which would have something like handle_event("tile-clicked", %{"id" => tile_id}, socket)
  4. Update the state on the assigns, which prompts the re-render
  5. Browser gets the entire row that was changed.

I had to change it a fair bit in order to reduce the count:

  1. Mount the live view entire grid with the grid state as a live view, but only use it as template
  2. Instead of rendering the cells as is, render them in a stateful component.
  3. When the cell is clicked, the component’s handle_event/3 callback is called
  4. Send a message to the live view via send(self(), {YourLiveViewComponent, tile_id, your_message})
  5. Have the parent view inspect the message and based off your logic, send the component an message with it’s updated assigns.
  6. This time the cell that changed only gets the patch.

You could have a layout like this:

defmodule YourLiveView do
  use YourAppWeb, :live_view  

  # The rest omitted, but in your `leex` tempate should have render_component with the ID

  def handle_info({:cell_update, component_id}, socket) do
    # Check the state from the socket to get a new value
    send_update(YourCellComponent, id: "component-#{component_id}", value: new_value)
    {:noreply, socket}
  end
end
defmodule YourCellComponent do
  use YourAppWeb, :live_component

  # The rest is omitted, but let's assume there's `:value` in the `assigns`.

  def handle_event("cell-update", _params, socket) do
    send(self(), {:cell_update, socket.assigns.id})
    {:noreply, socket}
  end
end

You can see a (poorly coded) example here: https://github.com/nathancyam/battleship_ex, but specifically these files:

2 Likes

That is very interesting. I need to read up more on this technique and look through your code (thank you very much for sharing).

Staring at my code more made me think that maybe the culprit is how I’m starting my for loop:
<%= for row <- @rows do %>
since the loop is using @rows it made me think that when I add a row I am making a change to @rows and that is enough to tell LiveView to redo the full for loop anytime the @rows changes.

That made me think that if I stored the rowCount and columnCount in the socket and used an indexed based array to pull the data out of the data structure that might solve my issue. The problem is I’m super new to Elixir, so not sure what an Elixir index based array is, but I am able to fake it with the Enum.at, so I wrote this:

<%= for row <- 0..@rowCount do %>
  <tr id="tr-<%= row %>">
  <%= for cell <- 0..@columnCount do %>
    <td class="bg-gray-50 border-gray-200 border-2">
      <div
      tabindex="0"
      class="font-mono w-32 overflow-hidden whitespace-nowrap pl-1 py-0.5 text-gray-700"
      >
        <%= Enum.at(Enum.at(@rows,row).cells,cell).string %>
      </div>
    </td>
  <% end %>
  </tr>
<% end %>

the main changes being the two for loops and the Enum.at line.

My test with that had the same result, and I think that is because when @rowCount changes the exact same thing happens with the for loop needing to be redone.

I have another idea that likely would work, but would make my code much more difficult to read and maintain. Instead of dynamically changing the number of rows and columns in the HTML based on the users window size, I could hardcode a whole bunch of rows and columns and use CSS to hide rows and columns that would be beyond their screen size. Or have a parent container of the table that is their window size and prevent the table from being responsive.

I hate to hardcode a limit where I didn’t intend to have one, and I hate to send more cells to the browser if they won’t be used as I’m sure that it still consumes memory and CPU, and I hate to make my code more difficult to read, but this might solve the diff issue.

I am going to read through your example.

I can easily convince myself that I’m pre-optimizing here and to stick with my original design and be fine with the extra data that will be sent over the socket with each change, but it will be a large grid and I want it performant. People will resize their screens way less often than they will be changing data in cells, but at this moment my guess is that LiveView would resend all rows to the browser even if the only thing that changed was a string in one cell, so that might be a moot point.

Plus this is helping me understand the LiveView much better.