Unexpected page reload on push_patch from child live_component

I have a pretty standard LiveView flow for user onboarding that uses a child Live Component that is presented modally when a corresponding live_action is present in handle_params.

Upon submitting the form in the child Live Component, the parent LiveView’s handle_params is called with :index as the live_action, which correctly dismisses the modal. However, immediately after, another GET request is issued with /new appended to the path, which leads to the modal being re-presented. It seems that something is causing the LiveView process to restart, which leads to a new request.

My context calls an external API using the Req module, if the request is successful it creates a user in my database with Ecto and returns {:ok, user}.

Here’s what I’ve done to debug:

  • If I remove the call to the external API and simply insert the user into the database, it works correctly with no new GET request.
  • If I replace the Req call with Process.sleep(5000) it works fine too.

So, there seems to be some issue with my request. The strange thing is that I’ve confirmed that the response is valid and returns correctly. So, I’m at a loss.

Here’s my request code:

# creates a user on an external server
def create_user(email, password) do
  post_request(&create_user_request/1, email: email, password: password)
end

defp post_request(request_function, form_data) do
  with {:ok, response} <- request_function.(form_data),
         {:ok, _body} <- parse_response do
    {:ok, form_data[:email]}
  end
end

defp create_user_request([email: _, password: _] = form_data) do
    base_request()
    |> Req.post(url: "/path", form: form_data)
end

defp parse_response(%{status: status, body: body}) do
  case status do
    200 ->
      {:ok, body}
   
    400 ->
      {:error, body}

    403 ->
      {:error, body}

     _ ->
       {:error, "Request failed."}
  end
end

defp base_request() do
  base_url: config[:base_url],
  auth: {:basic, "#{config[:login]}:#{config[:password]}",
  receive_timeout: 30_000  # the external server can be slow...
end

And, my context’s code:

def onboard_user(%{email: email, password: password} = attrs) do
    with changeset when changeset.valid? <- changeset_for_user(attrs),
         {:ok, _user_email} <- ExternalAPI.create_user(email, password),
         {:ok, user} <- insert_user(attrs) do
      {:ok, user}
    else
      %Ecto.Changeset{} = changeset ->
        {:error, changeset}

      {:error, message} ->
        {:error, message}
    end
  end

Finally, my FormComponent’s save_user function (called from handle_event):

def save_user() do
  case Accounts.onboard_user(user_params) do
      {:ok, user} ->
        notify_parent({:saved, user})

        {:noreply,
         socket
         |> put_flash(:info, "Created user #{user.email}")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}

      {:error, message} ->
        {:noreply,
         socket
         |> put_flash(:error, message)
         |> push_patch(to: socket.assigns.patch)}
    end
end

defp notify_parent(msg), do: send(self(), {__MODULE__, msg})

If this were happening there would be logs, what do you see in the logs?

That’s what’s frustrating… There isn’t anything in the logs to indicate that the LiveView process restarts other than that a log I put in to print that handle_params is called with :index (/users), which is immediately followed by a GET request to /users/new.

Another reason I suspect that the LiveView process is restarting is that I briefly see (behind the modal) a red flash message saying “difficulty connecting” or the same message that displays when I restart mix phx.server while my browser is pointing to a local instance. The debug logs look normal, the Req request completes successfully (I print the result tuple) and the user is correctly inserted via Ecto (confirmed in logs and shows up upon mount being called again due to the HTTP request).

Interesting, do you see any errors in the browser console? If you do IO.inspect(self()) in mount/1 do you see multiple pids? A fresh page load should generate exactly 2, one from the static load, and one from the live load. If you see more than that then the live view is indeed crashing.

What does the event handler in the parent LiveView look like for this message? Any chance it’s somehow causing the unexpected behavior?

I’m not sure why I hadn’t thought to look in the browser console before, but you’re right, there is some interesting info there:

phx-random_string destropyed: the child has been removed from the parent - - Undefined
phx-random_string timeout: received timeout while communicating with server. Falling back to hard refresh for recovery - undefined

--- Page Reloaded ---

As I indicated above, I have set the receive_timeout to a large number as requests to the external service unfortunately can take quite long to return. But, I did test with a very long Process.sleep (15_000), which didn’t result in the same timeout. So, I’m not sure where this timeout is happening.

  @impl true
  def handle_info({FormComponent, {:saved, user}}, socket) do
    {:noreply, stream_insert(socket, :users, user)}
  end

note: FormComponent is aliased at the top of the module

Ok, if I set a sufficiently large delay with Process.sleep(50_000) I can reproduce the same behavior. It seems that handle_event/3 has some sort of timeout that is exceeded. Is this documented anywhere?

Ahh, all of the lifecycle callbacks are synchronous with the exception of handle_async/3. In the module docs for Phoenix.LiveView, the sections on Async Operations and Endpoint Configuration seem relevant for what you’re doing.

Sometimes you need lower level control of asynchronous operations, while still receiving process isolation and error handling. For this, you can use start_async/3 and the Phoenix.LiveView.AsyncResult module directly:

def mount(%{"id" => id}, _, socket) do
  {:ok,
   socket
   |> assign(:org, AsyncResult.loading())
   |> start_async(:my_task, fn -> fetch_org!(id) end)}
end

def handle_async(:my_task, {:ok, fetched_org}, socket) do
  %{org: org} = socket.assigns
  {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end

I’d try calling start_async/3 from handle_event/3 and then calling stream_insert/4 from within the corresponding handle_async/3.

LiveView accepts the following configuration in your endpoint under the :live_view key: …

  • :hibernate_after (optional) - the idle time in milliseconds allowed in the LiveView before compressing its own memory and state. Defaults to 15000ms (15 seconds)

This might be the configuration you’re looking for. The default 15 seconds happens to be between the working 5 seconds and not working 50 seconds delays you’ve already tried.

1 Like

Thank you for the detailed reply. I actually stumbed upon handle_async/3 as I was investigating how to kick off a task asynchronously from handle_event/3. Originally, I planned to use Task but was pleasantly surprised to find that handle_async/3 and related functions had been added in LiveView 0.20 (I was using 0.19 and wasn’t aware of the addition of the async LiveView functions in 0.20 or I would have upgraded earlier).

I upgraded to LiveView 0.20.3 and was in the process of doing just what you suggested when I saw your message come through. For those who stumble upon this thread in the future, here’s what I did specifically:

First, I kick off the job in handle_event/3 upon user submission. I wrapped my long-running context function in one that raises exceptions on receiving {:error, _} tuples to be compatible with handle_async/3 that basically simplifies the case statement and simply raises an error if any {:error, _} tuple is returned from the context function.

I moved the error handling logic to pattern-matched handle_async/3 functions. One of these matches on {:exit, %Ecto.Changeset{}} so that I can assign the changeset if applicable. The other error handling one simply uses push_flash/3 to report any non-changeset errors.

Finally, the success case (matched on {:ok, user}) still calls notify_parent to send a message to the parent, which then calls stream_insert. It returns {:noreply, socket |> push_patch(to: socket.assigns.patch)}.

I’m not sure if the above is the best way to handle this use-case so I’d like any feedback but it seems right to me. The only thing I’ll need to change is to disable user interaction while loading since phx-disable no longer works during the async call.

2 Likes