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)