netto

netto

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.

https://github.com/nettinho/live_global_var
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!

Marked As Solved

soup

soup

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).

Also Liked

ibarch

ibarch

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.

soup

soup

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.

ibarch

ibarch

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

Last Post!

ibarch

ibarch

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.

Where Next?

Popular in Questions Top

electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
nobody
Hi! In PHP: $_SERVER[‘SERVER_ADDR’] - in Elixir? Searched the docs for ip address and the web, no good results. Thanks!
New
RisingFromAshes
I’ve read in another post that it may be possible with a router helper - but I couldn’t find an appropriate one, and tbh, I’m still just ...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
9mm
I am constructing a JSON object (map) and I need to conditionally set a field. I’m trying to write proper elixir-way code… and I’m at a l...
New
fayddelight
I tried installing elixir 1.11.2 erlang 23.3.4 via asdf in my zsh shell. Enabled the versions locally and globally. When I list them ...
New
jason.o
In the code below, if the create action is not set to accept “extra_key” as an input, it errors out with a message shown above. Is there ...
New

Other popular topics Top

Brian
What is the proper way to load a module from a file in to IEX? In the python world, doing something like this pretty standard: from ....
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
sen
Hi All, I set a environment variables in dev.exs , like below code. when i start server, how can i set the ${enable} value? thanks. d...
New
dblack
I’ve got an issue with an app and I’ve no idea of how to troubleshoot it. I’m hoping someone here might have seen something similar. I p...
New
Patoshizzle
After calling mix ecto.create I get this error: 17:00:32.162 [error] GenServer #PID&lt;0.412.0&gt; terminating ** (Postgrex.Error) FATAL...
New
JorisKok
I have a server on AWS, and was running a load test using artillery. When looking at the Phoenix dashboard I see the Ports going to 100% ...
New

We're in Beta

About us Mission Statement