Why weren't LiveComponents implemented as processes?

I wouldn’t call a 700 Elixir file unwieldy,

One way to to organize many verses of handle_info is to have more than one tag in the message tuple. like {:card, EVENT_TO_CARD, data ...} so you can match on the first tag in handle_info and defer to another function in another module.

2 Likes

With the new telemetry for destroyed live components, you now have all the tools necessary to actually move the pubsub logic to the component itself if you want.

I’m not saying that you should or if it a good idea, just that you can, I leave to you to decide if it is something worth to try for your specific case :slight_smile:

For anyone that does want to spawn a process for specific LiveComponents, I have a post from earlier this year on how to do that:

The approach lets a LiveComponent subscribe to its own PubSub topics and implement a handle_info/2 callback, without some of the disadvantages of using attach_hook/4 in the parent LiveView (need for message routing, possibly ambiguous messages, requires the LiveView to handle subscribe/unsubscribe).

1 Like

I’m not sure if your current solution works for all cases, if I got it right, it uses phx-remove to send a message to the component to kill its process. But, as I mentioned above, phx-remove will not be called in children components if the parent component is removed.

It would be better if you used the telemetry event which guarantees that you will always know when the live component is destroyed from the server side.

Here is a small example that I did to play around with the idea, it starts a genserver ComponentProcess inside the component which handles the pub_sub messages and sends it back to component. And it uses the telemetry event to handle unmount.

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"},
    {:phoenix_pubsub, "~> 2.1"},
    # please test your issue using the latest version of LV from GitHub!
    {:phoenix_live_view,
     github: "phoenixframework/phoenix_live_view", branch: "main", override: true}
  ]
)

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

defmodule ComponentProcess do
  @moduledoc false

  use GenServer

  def stop(pid), do: GenServer.stop(pid, :normal)

  def start_link(lv_pid, cid, setup_fn), do: GenServer.start_link(__MODULE__, {lv_pid, cid, setup_fn})

  def init({lv_pid, cid, setup_fn}) do
    setup_fn.()

    {:ok, %{lv_pid: lv_pid, cid: cid}}
  end

  def handle_info(message, state) do
    %{lv_pid: pid, cid: cid} = state

    Phoenix.LiveView.send_update(pid, cid, message: message)

    {:noreply, state}
  end
end

defmodule MyComponent do
  use Phoenix.LiveComponent

  attr :id, :string, required: true

  slot :inner_block

  def live_render(assigns), do: ~H"<.live_component module={__MODULE__} {assigns} />"

  def update(%{message: message}, socket) do
    %{id: id} = socket.assigns

    IO.puts("GOT PUBSUB MESSAGE #{inspect(message)} in component #{id}")

    {:ok, socket}
  end

  # Already subscribed, do nothing
  def update(assigns, %{assigns: %{pid: _}} = socket), do: {:ok, assign(socket, assigns)}

  def update(assigns, socket) do
    if connected?(socket) do
      %{myself: myself} = socket.assigns
      %{id: id} = assigns

      IO.puts("Component #{id} subscribing to pub_sub")

      setup_fn = fn -> Phoenix.PubSub.subscribe(:pub_sub, "some_topic") end

      {:ok, pid} = ComponentProcess.start_link(self(), myself, setup_fn)

      {:ok, socket |> assign(assigns) |> assign(pid: pid)}
    else
      {:ok, assign(socket, assigns)}
    end
  end

  def handle_terminate(socket) do
    %{id: id, pid: pid} = socket.assigns

    IO.puts("Component #{id} unsubscribing from pub_sub")

    ComponentProcess.stop(pid)

    # throw :blibs
  end

  def render(assigns) do
    ~H"""
    <div id={@id}>{render_slot(@inner_block)}</div>
    """
  end
end

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

  def mount(_params, _session, socket) do
    {:ok, socket |> assign(show_outer?: true) |> assign(show_inner?: true)}
  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"""
  <div>
    <MyComponent.live_render :if={@show_outer?} id="outer_comp">
      outer component
      <MyComponent.live_render :if={@show_inner?} id="inner_comp">
        inner component
      </MyComponent.live_render>
    </MyComponent.live_render>

    <button phx-click="toggle_outer_comp">Toggle outer component</button>
    <button phx-click="toggle_inner_comp">Toggle inner component</button>
    <button phx-click="send_pub_sub_message">Send pub sub message</button>
  </div>
    """
  end

  def handle_event("toggle_outer_comp", _, socket) do
    %{show_outer?: show_outer?} = socket.assigns

    {:noreply, assign(socket, show_outer?: not show_outer?)}
  end

  def handle_event("toggle_inner_comp", _, socket) do
    %{show_inner?: show_inner?} = socket.assigns

    {:noreply, assign(socket, show_inner?: not show_inner?)}
  end

  def handle_event("send_pub_sub_message", _, socket) do
    Phoenix.PubSub.broadcast(:pub_sub, "some_topic", {:test, 1})

    {:noreply, socket}
  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

defmodule HandleTerminate do
  @moduledoc false

  require Logger

  def handle(_, _, %{socket: socket, component: component}, _) do
    if Kernel.function_exported?(component, :handle_terminate, 1) do
      component.handle_terminate(socket)
    end
  rescue
    error ->
      Logger.error(Exception.format(:error, error, __STACKTRACE__))
  catch
    error ->
      Logger.error(Exception.format(:error, error, __STACKTRACE__))
  end
end

:ok = :telemetry.attach("live_component_destroyed_handler", [:phoenix, :live_component, :destroyed], &HandleTerminate.handle/4, nil)

{:ok, _} = Phoenix.PubSub.Supervisor.start_link(name: :pub_sub)

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
4 Likes

This is much better, looks like the telemetry event wasn’t available when I first implemented this, and phx-remove was the only thing I could come up with. Thanks for the tip!

1 Like