State Management with Phoenix LiveView

Hi @PJUllrich, many thanks for incorporating changes.

I actually run into an interesting set of issues related to the internal implementation of the library. As the library internally uses PubSub it means that automatically all the changes within a store become asyncronous.
Which usually won’t be a problem but trying to test it proved to be a challenge. As soon as I added DB operations in the store actions it seems to start to fail with:


Client #PID<0.743.0> is still using a connection from owner at location:

I want to avoid injecting :timer.sleep(1000) to wait for my handlers to finish.
I’m wondering do you have some ideas about how to test those scenarios ?

Hello Maxim,

Could you share some of your testing code please? Maybe a assert_receive or such would help here?

I also realised that the store actually always has the same topic. So, if you open multiple LiveViews, they’ll share the state :smiley: I’m fixing this later today by adding the LiveView PID to the topic.

Yeah sure, here it’s:

  test "on submit creates files uploaded", %{conn: conn} do
      {:ok, view, _html} = live_isolated(conn, FileUploadView)
      file = upload_test_file(view)
      render_upload(file, "sample.jpeg")

      view |> element("form") |> render_submit()

      assert has_element?(view, "#files-display", "1 file uploaded")
    end

# In store:
 def files_uploaded(payload, socket) do
    IO.puts("FILES UPLOADED>>>>")
    files = payload.files

    created_files =
      Enum.map(files, fn %{meta: meta, entry: entry, url: url, bucket: bucket} ->
        {:ok, file} = Files.create_file_from_s3_upload(meta, entry, %{url: url, bucket: bucket})
        file
      end)
      assign(socket, :files, created_files)
end

When all the file creation code was sync inside event handler no issues testing it. Now as it’s async I would need to wait on something to complete, so I’m not sure what to wait on. I tried assert_receive but it’s still too early. It needs to wait until handler actually finishes processing of files and creates all of them

Hmm, testing events is always a bit tricky in my experience. This is why I usually write myself a ConcurrencyHelper which is a sugarcoated :timer.sleep, but a bit more sophisticated. Here’s my code:

defmodule Support.ConcurrencyHelper do
  def wait_until(fun), do: wait_until(500, fun)

  def wait_until(0, fun), do: fun.()

  def wait_until(timeout, fun) do
    try do
      fun.()
    rescue
      ExUnit.AssertionError ->
        :timer.sleep(100)
        wait_until(max(0, timeout - 100), fun)
    end
  end
end

The problem you’re describing is not necessarily caused by live_ex, since any event handling could cause these timing issues. My recommendation is to wrap your assertion in a wait_until like this:

wait_until(fn ->
  assert has_element?(view, "#files-display", "1 file uploaded")
end)

has_element?/3 actually re-renders the view every time it is called, so you can use it to re-render the view every 100ms and check whether the upload was successful.

As a side-note: Do you mock the Files.create_file_from_s3_upload/3 function somehow? If it pulls the file from S3 and creates a local file, that would slow down your tests unneccessarily. You could mock the S3 call to expect a certain URL and return a random file. Also the file creation could lead to problems where e.g. in one test you create the file and expect it to exist and in another test you delete the file. If these tests run in parallel, you might have timing issues there as well. So, maybe consider simply returning :ok from the file creation if the content of a file fetched from the S3 mock equals an expected content. Just as a side-note though :slight_smile:

Do you mock the Files.create_file_from_s3_upload/3 function somehow? If it pulls the file from S3 and creates a local file,

It might be the confusing name. We are just creating instances of File in our database not downloading anything from S3. So it’s quite predictable func.

I was reading through your original article and noticed that you suggest doing all the side effects in handle_info:


  def handle_info(%{type: "add"} = action, socket) do
    # Perform any operation with the payload here. For example:
    new_state = socket.assigns.a + action.payload

    commit("set_a", new_state, socket)
  end

  # Mutations

  def set_a(payload, socket) do
    assign(socket, :a, payload)
  end

I did side effects at the mutation stage, could this be the source of the issue?
I did also noticed that for some reason my inner components are not detecting the change performed by the store mutation. :expressionless:

I did side effects at the mutation stage, could this be the source of the issue?

I’m sorry, I got that wrong back in the day. The state should only be changed in the Mutation and not the Action-Handler (which is the handle_info-function). Please try again according to the updated Readme. However, I’m not sure whether this will fix your problem with the detection of the updated store.

In order for the inner components to detect a state change, they need to get the state passed into them by the parent LiveView. So, if your store looks like this:

defmodule MyStore do
  use LiveEx

  @initial_store %{
    content: nil
  }

  def init(socket), do: init(@initial_store, socket)

  def handle_info(%{type: "set_content"} = action, socket) do
    commit("set_content", action.payload, socket)
  end

  def set_content(payload, socket) do
    assign(socket, :content, payload)
  end
end

You will need to pass the content to the live_component like this:

# In your LiveView .leex template
<%= live_component, MyLiveComponent, content: @content %>

Also, if you overwrote the update(assigns, socket) function in your MyLiveComponent, make sure that you’re actually setting the content assign. Like this:

defmodule MyLiveComponent do
  use Phoenix.LiveComponent

  def render(assigns) do
    ~L"""
    <div><%= @content %></div> 
    """
  end

  def update(assigns, socket) do
    {:ok, assign(socket, :content, assigns.content)}
  end
end

Yeah, I actually figured this one out. Somehow I thought it would magically appear there as I was modifying socket. LOL

So i’m a bit confused - do all side effects and db changes go to mutation then ? What’s the purpose of action then ?

Also, an Agent could be a solution instead of a GenServer, right?

Edit: Sorry, someone already said that before