How to get app.html.heex to use assigns properly with LiveView

assigns is not available as variables. give default values during mount . generally whatever value we want to use , are initialised to default value or nil during mount.

I need you to elaborate on that. If assigns[:user] exists, then I can either use assigns[:user] in the code or @user. They appear to do the same, differences being the first form is more explicit while the second more convenient.

It is also worth mentioning that assigns is not the same as @assigns. To have an @assigns in your code would be equivalent to assigns[:assigns], iirc.

Generally speaking, avoid accessing variables inside LiveViews, as code that access variables is always executed on every render. This also applies to the assigns variable.

https://hexdocs.pm/phoenix_live_view/assigns-eex.html#pitfalls

2 Likes

Yeah in the modern era you end up needing to pre assign(:current_user, nil) somewhere. You can enforce this at compile time by using a functional component for your header:

attr :current_user, :map, required: true
def header(assigns) do
  ~H""
end
3 Likes

Following @LostKobrakai 's advice I have updated the code to:

app.html.heex:

<header>

 <%= if not Enum.empty?(@user) do %>
   <h1> Cool Menu here for user <%= user.name %> </h1>
 <% end %>

</header>

# other code

user_login_live.ex:

defmodule WebInterface.MyAppLive do
  use WebInterface, :live_view

  @impl true
  def mount(_params, _session, socket), do: {:ok, assign(socket, :user, %{})}

  @impl true
  def handle_event("login", params, socket) do
    IO.puts("Seding async request")
    :ok = Manager.login(params)
    {:noreply, socket}
  end

  @impl true
  def handle_info({:login,  user, :done}, socket) do
    IO.puts("Authentication succeeded for user #{inspect(user)}")

    updated_socket =
      socket
      |> assign(:user, user)
      |> redirect(to: ~p"/cool_page")

    {:noreply, updated_socket}
  end

end

Which now errors with:

** (exit) an exception was raised:
    ** (KeyError) key :user not found in: %{
  __changed__: %{inner_content: true},
  flash: %{},
  inner_content: %Phoenix.LiveView.Rendered{
    static: ["<!--\r\n  This example requires updating your template:\r\n\r\n  ```\r\n  <html class=\"h-full bg-gray-100\">\r\n  <body class=\"h-full\">\r\n  ```\r\n-->\r\n<div class=\"min-h-full\">\r\n \r\n\r\n  <header class=\"bg-white shadow\">\r\n    <div class=\"mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8\">\r\n      <h1 class=\"text-3xl font-bold tracking-tight text-gray-900\">Activate</h1>\r\n    </div>\r\n  </header>\r\n  <main>\r\n    <div class=\"mx-auto max-w-7xl py-6 sm:px-6 lg:px-8\">\r\n      <!-- Your content -->\r\n    </div>\r\n  </main>\r\n</div>"],
    dynamic: #Function<0.92593063/1 in WebInterface.ActivateLive.render/1>,
    fingerprint: 220397200052326388454228465496201353196,
    root: false,
    caller: :not_available
  },
  live_action: nil,
  socket: #Phoenix.LiveView.Socket<
    id: "phx-F3kV873WgkDirAQB",
    endpoint: WebInterface.Endpoint,
    view: WebInterface.ActivateLive,
    parent_pid: nil,
    root_pid: nil,
    router: WebInterface.Router,
    assigns: #Phoenix.LiveView.Socket.AssignsNotInSocket<>,
    transport_pid: nil,
    ...
  >
}

I don’t quite understand how it is complaining about the key when a default value is set.

Without a stacktrace it is hard to help debug that error.

Tangentially I think a default of nil is a lot more intuitive than %{}.

2 Likes

Seems to me everyone is focussing on the wrong thing… The assign(:user, user) here won’t do much since you immediately redirect, which reloads the page and lose the state.

Do you somehow expect the user assign to be kept? Maybe you want an on_mount lifecycle hook to set the user assign.

2 Likes

Yes!

To this effect, would push_navigate not work here (instead of redirect)?

https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#push_navigate/2

How would I do it?

I’m not sure exactly how your application is doing the authentication, but looking at LiveBeats as a starting point should get you going.

In your router, you can define an on_mount hook.
See doc here or here.

See the examples of on_mount hooks in user_auth.ex.

I don’t think this process is applicable to my use case.

I am simply making a request to an API, and then saving the parsed answer. It is so simple, I think calling it authentication is a stretch.

Perhaps a cookie would make more sense here?

“Authentication” just means “login a user”, it’s certainly not a misnomer no matter how simple it is!

I’m assuming you’re getting this error because /cool_page is a different LiveView (which I’m gathering from view: WebInterface.ActivateLive). You have set the default user in the mount of WebInterface.MyAppLive which then redirects to /cool_page which I’m assuming is WebInterface.ActivateLive. Since this is a totally different LiveView, it’s a totally new socket (as @champeric pointed out).

Yes, if you are logging in a user you have to store a successful login id in a session. The thing is, you can’t set session data over a websocket. This is a websocket limitation, not a LiveView one. You will have to create a controller that looks something like this:

defmodule WebInterface.LoginController do
  use Phoenix.Controller

  def login(conn, params) do
    user = Manage.login(params)
    
    conn
    |> put_session(:user_id, user.id)
    |> redirect(to: ~p"/cool_page")
  end
end

This is where on_mount comes in. It’ll look very roughly like this:

def on_mount(:admin, _params, %{"user_id" => user_id}, socket) do
  {:ok, user} = Manage.get_logged_in_user(user_id)

  {:cont, Phoenix.Component.assign_new(socket, :user, user)}
end

These docs are well worth reading—they go into a lot more detail.

Correct !

Given this is for a Desktop application, wouldn’t it make more sense to perhaps use an Agent or ETS tables for this purpose?

Would it be any major differences VS having to create a controler and doing it that way?

I also read about not accessing assigns directly in a template. But I always wondered why that seems to be OK to do in the root template of a newly generated phoenix app?

e.g.

Because the root template is never live. It can’t be updated by LV in the first place. Therefore LV related constraints do not apply.

3 Likes

You would still need a way to figure out the current user by having something on their machine, so ETS wouldn’t do much good. I know nothing of using Phoenix for desktop apps… I assume it’s still going to be running in the browser just locally?? If so, you could use local storage to store the current user id. That’s pretty insecure but people do it anyway.

I am now using ETS for this purpose. Because this is a desktop application, no data will ever leave the user’s machine. Security, this way, is considerably more relaxed. My application will also only have 1 user at any one time so that also simplifies things, which makes ETS a good choice.

I am using a temporal persistence layer:

defmodule WebInterface.Persistence do
  @moduledoc """
  Responsible for temporary persistence in WebInterface. Uses ETS beneath the scenes.
  """

  alias ETS
  alias Shared.Data.User

  @table_name :data

  @spec init :: :ok | {:error, any}
  def init do
    with {:ok, _table} <- ETS.KeyValueSet.new(name: @table_name, protection: :public) do
      :ok
    end
  end

  @spec set_user(User.t) :: :ok | {:error, any}
  def set_user(%User{} = user) do
    with {:ok, table} <- ETS.KeyValueSet.wrap_existing(@table_name),
      {:ok, _updated_table} <- ETS.KeyValueSet.put(table, :user, user) do
        :ok
      end
  end

  @spec get_user :: {:ok, User.t} | {:error, any}
  def get_user do
    with {:ok, table} <- ETS.KeyValueSet.wrap_existing(@table_name) do
      ETS.KeyValueSet.get(table, :user)
    end
  end

  @spec has_user? :: boolean
  def has_user? do
    with {:ok, user} <- get_user() do

      if is_nil(user) do
        false
      else
        true
      end

    end
  end
end

Now in my app file, I simply ask the persistence module if I have the data I want. This is quite transparent:

app.html.heex:

<header>
 <%= if WebInterface.Persistence.has_user?() do %>
  <h1> <%= @user.name %> is awesome !</h1>
  <% end %>

</header>
<main class="">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

And I set/get the data I want via the Persistence API.

user_login_live.ex:

defmodule WebInterface.UserLoginLive do
  use WebInterface, :live_view

  require Logger

  alias Manager
  alias Shared.Data.{Credentials, User}
  alias WebInterface.Persistence

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_event("login", %{"email" => email, "password" => password} = params, socket) do
    :ok =
      email
      |> Credentials.new(password)
      |> Manager.login(Map.has_key?(params, "remember-me"))

      # show spninning wheel animation
      {:noreply, socket}
  end

  @impl true
  def handle_info({:login, %User{} = user, :done}, socket) do
    Logger.info("Authentication succeeded for user #{inspect(user)}")

    :ok = Persistence.set_user(user)

    {:noreply,  socket |> redirect(to: ~p"/activate")}
  end
end

For now this solution works wonderfully. No assigns dark magic.

Basically yes. To this end, I recommend GitHub - elixir-desktop/desktop: Elixir library to write Windows, macOS, Linux, Android apps with OTP24 & Phoenix.LiveView which I am using for this.

Given that:

  • this is a desktop app
  • i only have 1 user at any time

I think this is safe enough. Let me know if I am wrong :smiley:

I have learned a lot about LV and sockets here. Thanks everyone for the help!

Just keep an eye on how it behaves when that user has multiple tabs open

1 Like

With Elixir desktop this is not a problem. Unless the user specifically opens this in a web browser (in which case he needs to know which ports to use and the URL and so on, which are hidden from the user).

There is an option called “Open in Browser” in the default Elixir Desktop app, which my app still has, and this is the only way where a user could open the app in a web browser, and thus use multiple tabs (I will proceed to remove this option).

Were that to happen, the session would be overwritten with the new login data. Not something serious.

But thanks for the consideration !

For the sake of completeness, I have decided to post my final answer. This curated answer is mostly a resume of this huge thread that focuses on the most relevant issues and explores other options I also found. I hope future readers find it interesting.


Why this happens

So, after a lot of investigation and help from the community I found out what is happening.

Turns out that session data (data that needs to travel between multiple pages) cannot be shared over websockets. LiveViews use Websockets and therefore suffer from this limitation (How to get app.html.heex to use assigns properly with LiveView - #18 by sodapopcan):

Yes, if you are logging in a user you have to store a successful login id in a session. The thing is, you can’t set session data over a websocket. This is a websocket limitation, not a LiveView one ( … )

This also means you cant use cookies/alter them (Persisting data across liveview navigation - #4 by soup):

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

Possible solutions

However, this is not the end. There are other options if you need to share data between websockets. I was able to identify 4:

  1. Add the session data to the URL of the target page. An example of this is using GET HTTTP method with session data as parameters (or perhaps using post): elixir - How to get app.html.heex to use assigns properly with LiveView - Stack Overflow
  2. Save the data inside a global ETS table in the server: Persisting data across liveview navigation - #4 by soup
  3. Save the data in an Agent. You could have an Agent per websocket, thus avoiding a global state and improving efficiency: Persisting data across liveview navigation - #4 by soup
  4. Store the data in a Phoenix Session: How to get app.html.heex to use assigns properly with LiveView - #18 by sodapopcan

Here you have to weight the risks/benefits of every solution. If you have your own website, solution 4 would probably be the most secure, while solution 1 would also be OK provided you encrypt the data before putting it in the url parameters or post body request.

However, in my specific case, a Windows desktop application for a single user, these considerations are not important. I have therefore opted for solution 2, since it is simpler than solution 3 and the data there is mostly read only.

Final code

So now I am using a temporal persistence layer to store session information:

defmodule WebInterface.Persistence do
  @moduledoc """
  Responsible for temporary persistence in WebInterface. Uses ETS beneath the scenes.
  """

  alias ETS
  alias Shared.Data.User

  @table_name :data

  @spec init :: :ok | {:error, any}
  def init do
    with {:ok, _table} <- ETS.KeyValueSet.new(name: @table_name, protection: :public) do
      :ok
    end
  end

  @spec set_user(User.t) :: :ok | {:error, any}
  def set_user(%User{} = user) do
    with {:ok, table} <- ETS.KeyValueSet.wrap_existing(@table_name),
      {:ok, _updated_table} <- ETS.KeyValueSet.put(table, :user, user) do
        :ok
      end
  end

  @spec get_user :: {:ok, User.t} | {:error, any}
  def get_user do
    with {:ok, table} <- ETS.KeyValueSet.wrap_existing(@table_name) do
      ETS.KeyValueSet.get(table, :user)
    end
  end

  @spec has_user? :: boolean
  def has_user? do
    case get_user() do
      {:ok, nil} -> false
      {:ok, _user} -> true
      _ -> false
    end
  end
end

Now in my app file, I simply ask the persistence module if I have the data I want. This is quite transparent:

app.html.heex:

<header>
 <%= if WebInterface.Persistence.has_user?() do %>
  <h1> <%= @user.name %> is awesome !</h1>
  <% end %>

</header>
<main class="">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>

And I set/get the data I want via the Persistence API.

user_login_live.ex:

defmodule WebInterface.UserLoginLive do
  use WebInterface, :live_view

  alias Manager
  alias Shared.Data.{Credentials, User}
  alias WebInterface.Persistence

  @impl true
  def mount(_params, _session, socket), do: {:ok, socket}

  @impl true
  def handle_event("login", %{"email" => email, "password" => password} = params, socket) do
   # this is an async request for authentication
   # here we simply start it 
   :ok =
      email
      |> Credentials.new(password)
      |> Manager.login(Map.has_key?(params, "remember-me"))

      {:noreply, socket}
  end


  @impl true
  def handle_info({:login, %User{} = user, :done}, socket) do
    # when we receive this message, we know authentication is done.
     
    # and in other places we use get_user/0 to retrieve session data
    :ok = Persistence.set_user(user)

    {:noreply,  socket |> redirect(to: ~p"/other_page")}
  end
end

For now this solution works wonderfully.

2 Likes

Thanks a lot for leaving that here :heart: