Proposal: Support "memory releasable" assigns

Currently, every dynamic data that you want your LV to render needs to be stored in LV’s state as an assign.

This creates some specific challenges when you want your LV’s to consume as little memory as possible so it scales better.

For example, let’s say I have a Property schema and a LV that will load a property and render it.

That Property schema has a bunch of fields (address, baths, beds, square feet, images, etc), but all of these fields are expected to be “static”, meaning that I will render a page showing the property address, but I don’t have any other code that will use it. In other words, I loaded the schema struct just to render it to the user, but I don’t use it to do anything else inside my LV (except, perhaps, the property id field).

This means that I want to render all information about the property in my LV, but there is no real reason to store all that information in the LV’s socket assigns.

This proposal tries to tackle this scenario by allowing the user to add a special assign that is “ephemeral”. It would work like this:

The user has some state that they need to render but don’t need to store in the LV’s state:

def ...(..., socket) do
  property = load_property()

  socket = socket |> assign(property_id: property.id) |> temp_assign(property: property) 
end

def render(assigns) do
  ~H"""
  <div>Address: <%= @temps.property.address %></div>
  <div>Square Feet: <%= @temps.property.square_feet %></div>
  <div :for={image <- @temps.property.images}>Image: <img src={image} /></div>
  ...
  """
end

The above code will store property_id inside the socket assigns map as expected, and property into a temps assigns map that will be reset to %{} after the subsequent render.

After that render, unless the temps assigns map has a key property again, it will just ignore all calls to @temps.property in the ~H code.

If, later, the user calls temp_assign(socket, property: property) again with a new property, then the LV’s engine will see that the property key is available again in the temps assigns map again and re-render it.

Any suggestions and improvements are welcome.

There are temporary_assigns which was used before streams. Not sure if it will be deprecated or not. I would think not as you can use it for this purpose.

It is not the same thing, temporary_assigns can only be set in a LV’s mount/3 call, you can’t use it inside a live_component or with more modern features like handle_async, etc.

1 Like

Ohhhhh fair enough. Ya this would indeed be nice (although I clearly never run into it much).

1 Like

This is not correct. In fact temporary_assigns is exactly what you are proposing here :smiley:

I’m attaching a single file example that demonstrates temporary assigns both inside a LiveComponent and with handle_async:

Application.put_env(:sample, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:plug_cowboy, "~> 2.5"},
  {:jason, "~> 1.0"},
  {:phoenix, "~> 1.7"},
  # please test your issue using the latest version of LV from GitHub!
  {:phoenix_live_view,
   github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
])

# build the LiveView JavaScript assets (this needs mix and npm available in your path!)
path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["install"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}

  def mount(_params, _session, socket) do
    socket
    |> assign(:property, %{foo: 1, bar: 2, baz: 3})
    |> assign(:count, 0)
    |> start_async(:property, fn ->
      Process.sleep(1000)
      %{foo: 4, bar: 5, baz: 6}
    end)
    |> then(&{:ok, &1, temporary_assigns: [property: nil]})
  end

  def handle_async(:property, {:ok, result}, socket) do
    {:noreply, assign(socket, :property, result)}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js"></script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js"></script>
    <%!-- uncomment to use enable tailwind --%>
    <%!-- <script src="https://cdn.tailwindcss.com"></script> --%>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket)
      liveSocket.connect()
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    <%= @inner_content %>
    """
  end

  def render(assigns) do
    ~H"""
    <%= @count %>
    <button phx-click="inc">+</button>
    <button phx-click="dec">-</button>

    <%= @property.foo %>
    <%= @property.bar %>
    <%= @property.baz %>

    <div style="border: 1px solid black">
      <.live_component id="lc" module={Example.LiveComponent} />
    </div>
    """
  end

  def handle_event("inc", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count + 1)}
  end

  def handle_event("dec", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count - 1)}
  end
end

defmodule Example.LiveComponent do
  use Phoenix.LiveComponent

  def mount(socket) do
    socket
    |> assign(:property, %{foo: 10, bar: 20, baz: 30})
    |> assign(:count, 0)
    |> start_async(:property, fn ->
      Process.sleep(1000)
      %{foo: 40, bar: 50, baz: 60}
    end)
    |> then(&{:ok, &1, temporary_assigns: [property: nil]})
  end

  def handle_async(:property, {:ok, result}, socket) do
    {:noreply, assign(socket, :property, result)}
  end

  def render(assigns) do
    ~H"""
    <div>
    <%= @count %>
    <button phx-click="inc" phx-target={@myself}>+</button>
    <button phx-click="dec" phx-target={@myself}>-</button>

    <%= @property.foo %>
    <%= @property.bar %>
    <%= @property.baz %>
    </div>
    """
  end

  def handle_event("inc", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count + 1)}
  end

  def handle_event("dec", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count - 1)}
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug(:accepts, ["html"])
  end

  scope "/", Example do
    pipe_through(:browser)

    live("/", HomeLive, :index)
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :sample
  socket("/live", Phoenix.LiveView.Socket)

  plug(Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix")
  plug(Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view")

  plug(Example.Router)
end

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

Temporary assigns tell LV to reset the value of the assign to the value given in mount and only re-render the parts of the template using the assign when it is assigned again.

4 Likes

You are totally correct! I just tested it myself with your code and it worked great, now I feel dumb to not realizing that myself :sweat_smile:

Ha, I also am feeling dumb :poop: