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_mountto 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
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
Agentto store the state (using themix phx.gen.authuser_tokenas 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
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
I think you have two problems,
- Identifying users consistently
- 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
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
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.
Popular in Questions
Other popular topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #deployment
- #library
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #channels
- #elixirconf
- #exunit
- #discussion
- #code-sync
- #javascript
- #podcasts
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixir-ls
- #phoenix_html
- #iex
- #blog-post
- #graphql
- #genstage
- #ai
- #websockets
- #supervisor
- #elixirconf-us
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #metaprogramming
- #security
- #hex










