Persisting data across liveview navigation

I need to persist a value to a user session navigating between liveviews. I have found similar use cases describing some “cart” functionality but no solution fit. Most of the info is pre phoenix 1.7 without sigil_p, avoiding Phoenix.Router.Helpers and other goodies.
So I hacked a solution to explain the problem and maybe someone could help me understand what I missed to have this so complicated.

Here is the plain mix phx.new --no-ecto project, and I will comment the only changes I made.

In the app.html.heex I have a select:

<header class="px-4 sm:px-6 lg:px-8">
  <ul>
    <li><.link href={~p"/"}>Home</.link></li>
    <li><.link href={~p"/view1"}>View1</.link></li>
    <li><.link href={~p"/view2"}>View2</.link></li>
  </ul>

  <form phx-change="global-var-change">
    <label>Select option</label>
    <select name="global_var_select">
      <option
        :for={{label, value} <- @options}
        selected={value == @global_var}
        value={value}>
          <%= label %>
      </option>
    </select>
  </form>
  <div>Current value: <%= @global_var %></div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <%= @inner_content %>
  </div>
</main>

On the app Web module LiveGlobalVarWeb I implement handle_event/3 to handle global-var-change on all liveviews:
Note hardcoded_get_path/2. I wasn’t able to build the current URL from the socket. I tried enabling the Phoenix.Router.Helpers but wasn’t able.
I also realised that the socket has a :host_uri but for some reason the :path attribute is nil.

defmodule LiveGlobalVarWeb do
...
  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {LiveGlobalVarWeb.Layouts, :app}

      unquote(html_helpers())

      defp hardcoded_get_path(LiveGlobalVarWeb.View1Live, params), do: ~p"/view1?#{params}"
      defp hardcoded_get_path(LiveGlobalVarWeb.View2Live, params), do: ~p"/view2?#{params}"
      defp hardcoded_get_path(_, params), do: ~p"/?#{params}"

      def handle_event("global-var-change", %{"global_var_select" => value}, socket) do
        path = hardcoded_get_path(socket.view, %{"global_var" => value})

        #push_patch/2 and push_navigate/2 requests don't go through the plug pipelines
        {:noreply, redirect(socket, to: path)}
      end
    end
  end
...
end

And this is the Router that put it all together:

  • Puts the value in the session if it comes in the params
  • on_mount to assign the select options and the “global value” from the session
defmodule LiveGlobalVarWeb.Router do
  use LiveGlobalVarWeb, :router

  @example_options [{"", nil}, {"Opt1", "1"}, {"Opt2", "2"}, {"Opt3", "3"}]

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :put_root_layout, {LiveGlobalVarWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers

    plug :put_global_var
  end

  scope "/", LiveGlobalVarWeb do
    pipe_through :browser

    live_session :global_var, on_mount: [{__MODULE__, :mount_global_var}] do
      live "/", HomeLive
      live "/view1", View1Live
      live "/view2", View2Live
    end
  end

  # Plug to persist the value from them params if exists
  def put_global_var(%{params: %{"global_var" => value}} = conn, _) do
    put_session(conn, :global_var, value)
  end

  def put_global_var(conn, _), do: conn

  # `on_mount` to assign the select options and the "global value" from the session
  def on_mount(:mount_global_var, _params, session, socket) do
    global_var = Map.get(session, "global_var", nil)

    socket =
      socket
      |> Phoenix.Component.assign_new(:options, fn -> @example_options end)
      |> Phoenix.Component.assign_new(:global_var, fn -> global_var end)

    {:cont, socket}
  end
end

Is there a way to do this, or achieve this behaviour without having to navigate passing the URL params?

Thanks in advance!

2 Likes

I think you have two problems,

  1. Identifying users consistently
  2. Storing user data

Re 1,

Each liveview socket has an id field which can be used, and is persistent across navigation between live view processes in the same live_session, but refreshes, crashes or inter-session navigation will generate a new id, so its probably not persistent enough for what you want.

Your best bet is generating a cookie/local storage token and using that to look up data server side (or just dump it all in the cookie/ls). There is a good fly.io elixir-files post on using local storage.

(Note you cannot adjust cookies from a LV websocket, only on a true HTTP request. You can adjust local storage values from any JS event, so one may fit your needs better than the other (or both)).

(This is assuming your users are not reliably “logged in”, otherwise you can use your auth session id.)

Re 2,

Once you know a LV is attached to a specific user, you have a bazillion options for storing the data.

ETS is super simple (but actively frowned upon in the docs for session data as it doesn’t expire, you can write something to purge it), or you could use a Registry + GenServer (or Agent?) if it makes sense (perhaps there are associated Tasks etc). Both these options lose data when the server goes down!. These are also single-node, so any setup that may have users hit multiple servers will have issues.

Or just stick it in a db, or stick changes in a db and cache to ets/etc.

Loading the data into the socket can be done as you are already with the mount hook.

There were a few things in your example, where each link was href not navigate, unsure if that was intentional. I wouldn’t say this is your end goal, but maybe this will get you moving along.

diff --git a/lib/live_global_var/application.ex b/lib/live_global_var/application.ex
index 3b3a79d..0a3bf22 100644
--- a/lib/live_global_var/application.ex
+++ b/lib/live_global_var/application.ex
@@ -7,6 +7,8 @@ defmodule LiveGlobalVar.Application do
 
   @impl true
   def start(_type, _args) do
+    # definitely dont just start the ets table here ...
+    _table = :ets.new(:my_table, [:named_table, :public])
     children = [
       # Start the Telemetry supervisor
       LiveGlobalVarWeb.Telemetry,
diff --git a/lib/live_global_var_web.ex b/lib/live_global_var_web.ex
index 20b8dcc..cfa6599 100644
--- a/lib/live_global_var_web.ex
+++ b/lib/live_global_var_web.ex
@@ -56,14 +56,16 @@ defmodule LiveGlobalVarWeb do
 
       unquote(html_helpers())
 
-      defp hardcoded_get_path(LiveGlobalVarWeb.View1Live, params), do: ~p"/view1?#{params}"
-      defp hardcoded_get_path(LiveGlobalVarWeb.View2Live, params), do: ~p"/view2?#{params}"
-      defp hardcoded_get_path(_, params), do: ~p"/?#{params}"
+      # defp hardcoded_get_path(LiveGlobalVarWeb.View1Live, params), do: ~p"/view1?#{params}"
+      # defp hardcoded_get_path(LiveGlobalVarWeb.View2Live, params), do: ~p"/view2?#{params}"
+      # defp hardcoded_get_path(_, params), do: ~p"/?#{params}"
 
       def handle_event("global-var-change", %{"global_var_select" => value}, socket) do
-        path = hardcoded_get_path(socket.view, %{"global_var" => value})
+        :ets.insert(:my_table, {socket.id, value})
+        socket = assign(socket, :global_var, value)
+        # path = hardcoded_get_path(socket.view, %{"global_var" => value})
         # {:noreply, push_navigate(socket, to: path)}
-        {:noreply, redirect(socket, to: path)}
+        {:noreply, socket}
       end
     end
   end
diff --git a/lib/live_global_var_web/components/layouts/app.html.heex b/lib/live_global_var_web/components/layouts/app.html.heex
index e53aedb..fa9893b 100644
--- a/lib/live_global_var_web/components/layouts/app.html.heex
+++ b/lib/live_global_var_web/components/layouts/app.html.heex
@@ -1,8 +1,8 @@
 <header class="px-4 sm:px-6 lg:px-8">
   <ul>
-    <li><.link href={~p"/"}>Home</.link></li>
-    <li><.link href={~p"/view1"}>View1</.link></li>
-    <li><.link href={~p"/view2"}>View2</.link></li>
+    <li><.link navigate={~p"/"}>Home</.link></li>
+    <li><.link navigate={~p"/view1"}>View1</.link></li>
+    <li><.link navigate={~p"/view2"}>View2</.link></li>
   </ul>
 
   <form phx-change="global-var-change">
diff --git a/lib/live_global_var_web/router.ex b/lib/live_global_var_web/router.ex
index ac71302..685ee6b 100644
--- a/lib/live_global_var_web/router.ex
+++ b/lib/live_global_var_web/router.ex
@@ -30,14 +30,22 @@ defmodule LiveGlobalVarWeb.Router do
 
   # On mount to build the select options and assign the value from the session
   def on_mount(:mount_global_var, _params, session, socket) do
-    global_var = Map.get(session, "global_var", nil)
+    # :ets.tab2list(:my_table)
+    # |> IO.inspect()
+    value =
+      case :ets.lookup(:my_table, socket.id) do
+        [{_, v}] -> v
+        _ -> nil
+      end
+    socket = Phoenix.Component.assign_new(socket, :global_var, fn -> value end)
+    # global_var = Map.get(session, "global_var", nil)
 
     socket =
       socket
       |> Phoenix.Component.assign_new(:options, fn ->
         [{"", nil}, {"Opt1", "1"}, {"Opt2", "2"}, {"Opt3", "3"}]
       end)
-      |> Phoenix.Component.assign_new(:global_var, fn -> global_var end)
+    # |> Phoenix.Component.assign_new(:global_var, fn -> global_var end)
 
     {:cont, socket}
   end

Not sure if that’s helpful.

2 Likes

Thank you for the reply! I didn’t know about ETS and it looks pretty nice!

I really don’t care that this information is lost when the server go down. It is basically for the user to switch domains inside the application.
My other option when I was thinking of this was to spawn an Agent to store the state (using the mix phx.gen.auth user_token as key) but I wondered if it was overkill.
I just felt that this belonged next to the user_token in the session, but I can’t find a way to put_session it inside the liveview lifecycle.
If it is not possible, I definitely like your ETS approach better than the example.

Thanks again

Yeah, because LV is all over a websocket you cant alter the cookies so your stuck with local storage or another system.

From what I recall, you can do hacks like trigger a HTTP request from JS to adjust the cookies from LV, but it wont propagate into the LV, so you end up double handling everything.

My other option when I was thinking of this was to spawn an Agent to store the state (using the mix phx.gen.auth user_token as key) but I wondered if it was overkill.

In this case your agent would probably become a bottleneck where every view will have to wait for the agent to sequentially handle each “get_var” call as messages are processed sequentially,

Instead you probably want an Agent/GenServer per LV, so each handles its own “persistent state”

Previously I have done similar to what you describe, using the live_session_id from phx.gen.auth as a way to look up the correct genserver. phx.gen.auth also generates some broadcast messages on logout that you can use to tear down the servers (but I still have an automatic “no messages in N time, kill” callback (See send_after, Registry).

The ETS table has concurrent reads and a simpler & existing interface, so its probably the best “out of the box” option where you can just pair the records against your auth token or whatever, just be aware you need manage eviction or the memory will continue to grow.

I would preference ETS over separate genservers unless there is clear behaviour to associate with the data (eg: they can set the value and it should revert after n minutes or something or you’re defining workflows between liveviews that are dependent on other services, etc).

2 Likes

This hacky approach to put something into session from within Liveview may work for you.

In one of your liveviews:

assign(socket, :put_session, %{global_var: "foo"})

Add this line to the bottom of yourapp_web/components/layouts/app.html.heex

<iframe :if={@put_session} id="put-session" src={~p"/put_session?#{@put_session}"}></iframe>

The following rule might be unnecessary, but I recommend to add it to assets/app.css just in case:

iframe#put-session {
  display: none;
}

In yourapp_web/router.ex add the following line outside of any scope

get "/put_session", MyappWeb.PlugSessionController, :put

Create yourapp_web/controllers/plug_session_controller.ex with

defmodule YourappWeb.PlugSessionController do
  use YourappWeb, :controller

  def put(conn, params) do
    conn
    |> fetch_session()
    |> do_put(params)
    |> put_resp_content_type("text/html")
    |> send_resp(:ok, "<!DOCTYPE html><html>")
  end

  defp do_put(conn, %{"global_var" => global_var}) do
    global_var = do_some_validation_and_preparation(global_var)
    put_session(conn, :global_var, global_var)
  end

  defp do_put(conn, _params) do
    conn
  end
end

It makes sense. I wasn’t thinking that the session is set in the cookies, and that Liveview communication goes below it. Now wonder if this shouldn’t just be a JS hook that updates de session. I’m not sure it is possible, as it is encoded in the cookie. I’ll look into it.

Thanks for taking your time and sharing. Even though it avoids the url params stuff, I am not a fan of embedding a hidden iframe.

Just FYI, as with most of Phoenix’s underbelly, session is provided by Plug, which can make tracking the docs down a bit of a egg hunt.

There used to be an explicit guide but it seems to have dropped out of the later docs:

Old guide, briefly reading it, it doesn’t appear particularly wrong or misleading but it might have quirks: Sessions – Phoenix v1.3.0-rc.0

ctrl-f session: Plug — Phoenix v1.6.16

Plug.Session (cookie & ets): Plug.Session.COOKIE — Plug v1.14.0

This solution is flawed and insecure. After giving the problem some thought, I came up with something more suitable for production:

Define a helper function for Liveviews.

defmodule MyAppWeb.LiveViewUtils do
  use MyAppWeb, :verified_routes
  import Phoenix.LiveView

  def push_cookie(socket, key, value) do
    push_event(socket, "fetch-cookies", %{
      method: "PUT",
      path: ~p"/cookies",
      token: Phoenix.Token.sign(socket, "cookie", {key, value})
    })
  end
end

Catch this event on the client side in assets/js/app.js and send a fetch request back to the server (to a regular controller).

window.addEventListener("phx:fetch-cookies", ({ detail }) => {
  const { path, method, token } = detail;
  fetch(path, {
    method,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ token }),
  });
});

Add a route for this request:

put "/cookies", MyAppWeb.CookiesController, :put

Define a controller.

defmodule MyAppWeb.CookiesController do
  use MyAppWeb, :controller

  def put(conn, %{"token" => token}) do
    case Phoenix.Token.verify(conn, "cookie", token, max_age: 60) do
      {:ok, {key, value}} ->
        conn
        |> fetch_session()
        |> put_session(key, value)
        |> send_resp(:ok, "")

      _ ->
        send_resp(conn, :unauthorized, "")
    end
  end
end

The approach above can be easily extended to accommodate the push_delete_cookie/2 functionality.

5 Likes