Phoenix_live_view: There is no unmount callback on Phoenix.LiveView

Created on 24 Mar 2019  路  11Comments  路  Source: phoenixframework/phoenix_live_view

Actual behavior

There is a callback to track when the socket has been mounted, but there is no callback to be notified when the socket has been unmounted. This makes cleaning up the user's session difficult (like committing unsaved work to a database when the user closes a browser tab, for example).

Expected behavior

Have a callback for when the socket is unmounted.

def unmount(session, socket) do
  # clean up or post disconnect operation
  {:noreply, socket}
end

Most helpful comment

terminate only works if you are trapping exits, which you rarely want to do. To handle the view process going away, you'd handle it the same way you handle any other elixir process going away 鈥撀爕ou have another process monitor you. This should get you moving:

defmodule MyLive do
  use Phoenix.LiveView

  def mount(_, socket) do
    LiveMonitor.monitor(self(), __MODULE__, %{id: socket.id})
  end

  def unmount(%{id: id}, _reason) do
    IO.puts("view #{id} unmounted")
    :ok
  end
end

defmodule LiveMonitor do
  use GenServer

  def monitor(pid, view_module, meta) do
    GenServer.call(pid, {:monitor, pid, view_module, meta})
  end

  def init(_) do
    {:ok, %{views: %{}}}
  end

  def handle_call({:monitor, pid, view_module}, _, %{views: views} = state) do
    Process.monitor(pid)
    {:reply, :ok, %{state | views: Map.put(views, pid, {view_module, meta})}}
  end

  def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
    {{module, meta}, new_views} = Map.pop(state.views)
    module.unmount(reason, meta) # should wrap in isolated task or rescue from exception
    {:noreply, %{state | views: new_views}}
  end
end

All 11 comments

Looks like I can just use the terminate callback (https://github.com/phoenixframework/phoenix_live_view/blob/master/lib/phoenix_live_view.ex#L467). That'll work. Sorry for the pollution.

terminate only works if you are trapping exits, which you rarely want to do. To handle the view process going away, you'd handle it the same way you handle any other elixir process going away 鈥撀爕ou have another process monitor you. This should get you moving:

defmodule MyLive do
  use Phoenix.LiveView

  def mount(_, socket) do
    LiveMonitor.monitor(self(), __MODULE__, %{id: socket.id})
  end

  def unmount(%{id: id}, _reason) do
    IO.puts("view #{id} unmounted")
    :ok
  end
end

defmodule LiveMonitor do
  use GenServer

  def monitor(pid, view_module, meta) do
    GenServer.call(pid, {:monitor, pid, view_module, meta})
  end

  def init(_) do
    {:ok, %{views: %{}}}
  end

  def handle_call({:monitor, pid, view_module}, _, %{views: views} = state) do
    Process.monitor(pid)
    {:reply, :ok, %{state | views: Map.put(views, pid, {view_module, meta})}}
  end

  def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
    {{module, meta}, new_views} = Map.pop(state.views)
    module.unmount(reason, meta) # should wrap in isolated task or rescue from exception
    {:noreply, %{state | views: new_views}}
  end
end

I like this solution, but it reveals another problem: mount appears to be called twice with the same socket id but with different pids. I believe I would need to be able to rely on a stable pid/socket_id combination to make this usable. Perhaps there's another perspective I should consider?

[info] GET /
[debug] Processing with PhoenixGenWeb.PageController.index/2
  Parameters: %{}
  Pipelines: [:browser]
mount session: %{}, socket.id: phx-9bGz7y4A, pid: #PID<0.515.0>   <=== mount called
monitor #PID<0.515.0> .  <=== monitor setup
:DOWN #PID<0.515.0> .  <=== ah, maybe that pid is already dead??
[info] Sent 200 in 114ms
view phx-9bGz7y4A unmounted
[info] CONNECT Phoenix.LiveView.Socket
  Transport: :websocket
  Connect Info: %{}
  Parameters: %{"vsn" => "2.0.0"}
[info] Replied Phoenix.LiveView.Socket :ok
mount session: %{}, socket.id: phx-9bGz7y4A, pid: #PID<0.528.0> . <=== mount called again, diff pid
monitor #PID<0.528.0> <=== this monitor appears to work
[info] Replied phoenix:live_reload :ok

ahh nvm, I see it's the initial render, followed by the socket connect, where mount/2 is called twice and each disambiguated via connected?. Sorry for the noise.

You should use connected?(socket) to conditionally call the code path instead to avoid

Trying to follow this example and stumbling a bit... shouldn't the LiveView#monitor method be calling to it's own pid (aka self()), not the pid passed (which is of the socket connection)? And does this assume the GenServer has already had start_link somewhere earlier in the app? Or am I misunderstanding the GenServer relationship here?

Appreciate any guidance, really enjoying tinkering with LiveView!

From the _liveview process_, you would call LiveView.monitor(self(), __MODULE__, %{}). This is so that pid may be passed to Process.monitor(pid) so the _monitor_ will be notified of a :DOWN event when the liveview process is terminated. Yes, you will need to start the montior as a GenServer in the usual manner.

Right, I think what's throwing me is inside LiveMonitor#monitor when it calls GenServer.call(pid, {:monitor, pid, view_module, meta}). That's calling {:monitor} back to the live view process, isn't it? Instead, it should be invoking that on the PID coming back from the GenServer's start_link, shouldn't it?

Ah yes, this is what I ended up using, tailored for my needs, including the need to demonitor that you might not need:

    def monitor(socket_id, game_pid) do
      pid = GenServer.whereis({:global, __MODULE__})
      GenServer.call(pid, {:monitor, {socket_id, game_pid}})
    end

    def demonitor() do
      pid = GenServer.whereis({:global, __MODULE__})
      GenServer.call(pid, :demonitor)
    end

    def start_link(init_arg) do
      GenServer.start_link(__MODULE__, init_arg, name: {:global, __MODULE__})
    end

    def init(_) do
      {:ok, %{views: %{}}}
    end

    def handle_call({:monitor, {socket_id, game_pid}}, {view_pid, _ref}, %{views: views} = state) do
      mref = Process.monitor(view_pid)
      {:reply, :ok, %{state | views: Map.put(views, view_pid, {socket_id, game_pid, mref})}}
    end

    def handle_call(:demonitor, {view_pid, _ref}, state) do
      {{_socket_id, game_pid, mref}, new_views} = Map.pop(state.views, view_pid)
      :erlang.demonitor(mref)
      # more logic ...
      new_state = %{state | views: new_views}    
      {:reply, :ok, new_state}
    end

    def handle_info({:DOWN, _ref, :process, view_pid, _reason}, state) do
      # IO.puts(":DOWN #{inspect view_pid}")
      {{_socket_id, game_pid, mref}, new_views} = Map.pop(state.views, view_pid)
      # more logic ...
      new_state = %{state | views: new_views}
      {:noreply, new_state}
    end

That helped clarify those details for me a lot, thank you very much! Working great now.

terminate only works if you are trapping exits, which you rarely want to do.

It's trapping exits a bad idea? why?

My question is because if I use the unmount(reason, meta) approach, do I have some option to get the last state of the socket in unmount?

Thanks :)

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ccapndave picture ccapndave  路  4Comments

tfwright picture tfwright  路  3Comments

alexgaribay picture alexgaribay  路  4Comments

tmepple picture tmepple  路  4Comments

LightningK0ala picture LightningK0ala  路  5Comments