From 56697d304e7a2bba960e71c3ee3c4101af4fb769 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 31 Aug 2023 10:01:15 -0400 Subject: [PATCH] Add assign_async and start_async (#2763) --- lib/phoenix_component.ex | 49 ++- lib/phoenix_live_view.ex | 209 +++++++++- lib/phoenix_live_view/async.ex | 207 ++++++++++ lib/phoenix_live_view/async_result.ex | 112 ++++++ lib/phoenix_live_view/channel.ex | 75 +++- lib/phoenix_live_view/test/client_proxy.ex | 6 + lib/phoenix_live_view/test/live_view_test.ex | 51 ++- test/phoenix_live_view/async_result_test.exs | 25 ++ .../integrations/assign_async_test.exs | 177 +++++++++ .../integrations/hooks_test.exs | 3 + .../integrations/start_async_test.exs | 127 ++++++ .../phoenix_live_view/upload/channel_test.exs | 20 +- test/phoenix_live_view_test.exs | 13 + test/support/live_views/general.ex | 367 ++++++++++++++++++ test/support/router.ex | 2 + 15 files changed, 1428 insertions(+), 15 deletions(-) create mode 100644 lib/phoenix_live_view/async.ex create mode 100644 lib/phoenix_live_view/async_result.ex create mode 100644 test/phoenix_live_view/async_result_test.exs create mode 100644 test/phoenix_live_view/integrations/assign_async_test.exs create mode 100644 test/phoenix_live_view/integrations/start_async_test.exs diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 1fd7207a9b..c5edf21811 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -513,7 +513,7 @@ defmodule Phoenix.Component do ## Functions - alias Phoenix.LiveView.{Static, Socket} + alias Phoenix.LiveView.{Static, Socket, AsyncResult} @reserved_assigns Phoenix.Component.Declarative.__reserved__() # Note we allow live_action as it may be passed down to a component, so it is not listed @non_assignables [:uploads, :streams, :socket, :myself] @@ -2871,4 +2871,51 @@ defmodule Phoenix.Component do %><% end %> """ end + + @doc """ + Renders an async assign with slots for the different loading states. + + *Note*: The inner block receives the result of the async assign as a :let. + The let is only accessible to the inner block and is not in scope to the + other slots. + + ## Examples + + ```heex + <.async_result :let={org} assign={@org}> + <:loading>Loading organization... + <:failed :let={reason}>there was an error loading the organization + <%= if org do %> + <%= org.name %> + <% else %> + You don't have an organization yet. + <% end %> + <.async_result> + ``` + """ + attr.(:assign, AsyncResult, required: true) + slot.(:loading, doc: "rendered while the assign is loading") + + slot.(:failed, + doc: + "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." + ) + + slot.(:inner_block, + doc: + "rendered when the assign is loaded successfully via AsyncResult.ok/2. Receives the result as a :let" + ) + + def async_result(%{assign: async_assign} = assigns) do + cond do + async_assign.ok? -> + ~H|<%= render_slot(@inner_block, @assign.result) %>| + + async_assign.loading -> + ~H|<%= render_slot(@loading, @assign.loading) %>| + + async_assign.failed -> + ~H|<%= render_slot(@failed, @assign.failed) %>| + end + end end diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index a80d9d1bbe..6c7be140ff 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -307,9 +307,98 @@ defmodule Phoenix.LiveView do * [DOM patching and temporary assigns](dom-patching.md) * [JavaScript interoperability](js-interop.md) * [Uploads (External)](uploads-external.md) + + ## Async Operations + + Performing asynchronous work is common in LiveViews and LiveComponents. + It allows the user to get a working UI quicky while the system fetches some + data in the background or talks to an external service, without blocking the + render or event handling. For async work, you also typically need to handle + the different states of the async operation, such as loading, error, and the + successful result. You also want to catch any errors or exits and translate it + to a meaningful update in the UI rather than crashing the user experience. + + ### Async assigns + + The `assign_async/3` function takes a name, a list of keys which will be assigned + asynchronously, and a function that returns the result of the async operation. + For example, let's say we want to async fetch a user's organization from the database, + as well as their profile and rank: + + def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar") + |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end) + |> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + end + + Here we are assigning `:org` and `[:profile, :rank]` asynchronously. The async function + must return a `{:ok, assigns}` or `{:error, reason}` tuple, where `assigns` is a map of + the keys passed to `assign_async`. If the function returns other keys or a different + set of keys, an error is raised. + + The state of the async operation is stored in the socket assigns within an + `%AsyncResult{}`. It carries the loading and failed states, as well as the result. + For example, if we wanted to show the loading states in the UI for the `:org`, + our template could conditionally render the states: + + ```heex +
Loading organization...
+
<%= org.name %> loaded!
+ ``` + + The `Phoenix.Component.async_result/1` function component can also be used to + declaratively render the different states using slots: + + ```heex + <.async_result :let={org} assign={@org}> + <:loading>Loading organization... + <:failed :let={_reason}>there was an error loading the organization + <%= org.name %> + <.async_result> + ``` + + Additionally, for async assigns which result in a collection of items, you + can enumerate the assign directly. It will only enumerate + the results once the results are loaded. For example: + + ```heex +
<%= org.name %>
+ ``` + + ### Arbitrary async operations + + 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 `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 + + def handle_async(:my_task, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} + end + + `start_async/3` is used to fetch the organization asynchronously. The + `handle_async/3` callback is called when the task completes or exits, + with the results wrapped in either `{:ok, result}` or `{:exit, reason}`. + The `AsyncResult` module is used to direclty to update the state of the + async operation, but you can also assign any value directly to the socket + if you want to handle the state yourself. ''' - alias Phoenix.LiveView.{Socket, LiveStream} + alias Phoenix.LiveView.{Socket, LiveStream, Async} @type unsigned_params :: map @@ -667,6 +756,40 @@ defmodule Phoenix.LiveView do """ def connected?(%Socket{transport_pid: transport_pid}), do: transport_pid != nil + @doc """ + Puts a new private key and value in the socket. + + Privates are *not change tracked*. This storage is meant to be used by + users and libraries to hold state that doesn't require + change tracking. The keys should be prefixed with the app/library name. + + ## Examples + + Key values can be placed in private: + + put_private(socket, :myapp_meta, %{foo: "bar"}) + + And then retrieved: + + socket.private[:myapp_meta] + """ + @reserved_privates ~w( + connect_params + connect_info + assign_new + live_layout + lifecycle + root_view + __temp__ + )a + def put_private(%Socket{} = socket, key, value) when key not in @reserved_privates do + %Socket{socket | private: Map.put(socket.private, key, value)} + end + + def put_private(%Socket{}, bad_key, _value) do + raise ArgumentError, "cannot set reserved private key #{inspect(bad_key)}" + end + @doc """ Adds a flash message to the socket to be displayed. @@ -691,6 +814,7 @@ defmodule Phoenix.LiveView do iex> put_flash(socket, :info, "It worked!") iex> put_flash(socket, :error, "You can't access that page") """ + defdelegate put_flash(socket, kind, msg), to: Phoenix.LiveView.Utils @doc """ @@ -1874,4 +1998,87 @@ defmodule Phoenix.LiveView do |> Map.update!(:__changed__, &MapSet.put(&1, name)) end) end + + @doc """ + Assigns keys asynchronously. + + The task is linked to the caller and errors are wrapped. + Each key passed to `assign_async/3` will be assigned to + an `%AsyncResult{}` struct holding the status of the operation + and the result when completed. + + ## Examples + + def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar") + |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end) + |> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + end + + See the moduledoc for more information. + """ + def assign_async(%Socket{} = socket, key_or_keys, func) + when (is_atom(key_or_keys) or is_list(key_or_keys)) and + is_function(func, 0) do + Async.assign_async(socket, key_or_keys, func) + end + + @doc """ + Starts an ansynchronous task and invokes callback to handle the result. + + The task is linked to the caller and errors/exits are wrapped. + The result of the task is sent to the `handle_async/3` callback + of the caller LiveView or LiveComponent. + + ## Examples + + 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 + + def handle_async(:my_task, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} + end + + See the moduledoc for more information. + """ + def start_async(%Socket{} = socket, name, func) + when is_atom(name) and is_function(func, 0) do + Async.start_async(socket, name, func) + end + + @doc """ + Cancels an async operation if one exists. + + Accepts either the `%AsyncResult{}` when using `assign_async/3` or + the key passed to `start_async/3`. + + The underlying process will be killed with the provided reason, or + {:shutdown, :cancel}`. if no reason is passed. For `assign_async/3` + operations, the `:failed` field will be set to `{:exit, reason}`. + For `start_async/3`, the `handle_async/3` callback will receive + `{:exit, reason}` as the result. + + Returns the `%Phoenix.LiveView.Socket{}`. + + ## Examples + + cancel_async(socket, :preview) + cancel_async(socket, :preview, :my_reason) + cancel_async(socket, socket.assigns.preview) + """ + def cancel_async(socket, async_or_keys, reason \\ {:shutdown, :cancel}) do + Async.cancel_async(socket, async_or_keys, reason) + end end diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex new file mode 100644 index 0000000000..d5d1aa35e0 --- /dev/null +++ b/lib/phoenix_live_view/async.ex @@ -0,0 +1,207 @@ +defmodule Phoenix.LiveView.Async do + @moduledoc false + + alias Phoenix.LiveView.{AsyncResult, Socket, Channel} + + def start_async(%Socket{} = socket, key, func) + when is_atom(key) and is_function(func, 0) do + run_async_task(socket, key, func, :start) + end + + def assign_async(%Socket{} = socket, key_or_keys, func) + when (is_atom(key_or_keys) or is_list(key_or_keys)) and + is_function(func, 0) do + keys = List.wrap(key_or_keys) + + # verifies result inside task + wrapped_func = fn -> + case func.() do + {:ok, %{} = assigns} -> + if Enum.find(keys, &(not is_map_key(assigns, &1))) do + raise ArgumentError, """ + expected assign_async to return map of assigns for all keys + in #{inspect(keys)}, but got: #{inspect(assigns)} + """ + else + {:ok, assigns} + end + + {:error, reason} -> + {:error, reason} + + other -> + raise ArgumentError, """ + expected assign_async to return {:ok, map} of + assigns for #{inspect(keys)} or {:error, reason}, got: #{inspect(other)} + """ + end + end + + new_assigns = + Enum.map(keys, fn key -> + case socket.assigns do + %{^key => %AsyncResult{ok?: true} = existing} -> + {key, AsyncResult.loading(existing, keys)} + + %{} -> + {key, AsyncResult.loading(keys)} + end + end) + + socket + |> Phoenix.Component.assign(new_assigns) + |> run_async_task(keys, wrapped_func, :assign) + end + + defp run_async_task(%Socket{} = socket, key, func, kind) do + if Phoenix.LiveView.connected?(socket) do + lv_pid = self() + cid = cid(socket) + {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, key, func, kind) end) + + ref = + :erlang.monitor(:process, pid, alias: :reply_demonitor, tag: {__MODULE__, key, cid, kind}) + + send(pid, {:context, ref}) + + update_private_async(socket, &Map.put(&1, key, {ref, pid, kind})) + else + socket + end + end + + defp do_async(lv_pid, cid, key, func, async_kind) do + receive do + {:context, ref} -> + try do + result = func.() + Channel.report_async_result(ref, async_kind, ref, cid, key, {:ok, result}) + catch + catch_kind, reason -> + Process.unlink(lv_pid) + caught_result = to_exit(catch_kind, reason, __STACKTRACE__) + Channel.report_async_result(ref, async_kind, ref, cid, key, caught_result) + :erlang.raise(catch_kind, reason, __STACKTRACE__) + end + end + end + + def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do + case result do + %AsyncResult{loading: keys} when is_list(keys) -> + new_assigns = for key <- keys, do: {key, AsyncResult.failed(result, {:exit, reason})} + + socket + |> Phoenix.Component.assign(new_assigns) + |> cancel_async(keys, reason) + + %AsyncResult{} -> + socket + end + end + + def cancel_async(%Socket{} = socket, key, reason) do + case get_private_async(socket, key) do + {_ref, pid, _kind} when is_pid(pid) -> + Process.unlink(pid) + Process.exit(pid, reason) + update_private_async(socket, &Map.delete(&1, key)) + + nil -> + socket + end + end + + def handle_async(socket, maybe_component, kind, key, ref, result) do + case prune_current_async(socket, key, ref) do + {:ok, pruned_socket} -> + handle_kind(pruned_socket, maybe_component, kind, key, result) + + :error -> + socket + end + end + + def handle_trap_exit(socket, maybe_component, kind, key, ref, reason) do + handle_async(socket, maybe_component, kind, key, ref, {:exit, reason}) + end + + defp handle_kind(socket, maybe_component, :start, key, result) do + callback_mod = maybe_component || socket.view + + case callback_mod.handle_async(key, result, socket) do + {:noreply, %Socket{} = new_socket} -> + new_socket + + other -> + raise ArgumentError, """ + expected #{inspect(callback_mod)}.handle_async/3 to return {:noreply, socket}, got: + + #{inspect(other)} + """ + end + end + + defp handle_kind(socket, _maybe_component, :assign, keys, result) do + case result do + {:ok, {:ok, %{} = assigns}} -> + new_assigns = + for {key, val} <- assigns do + {key, AsyncResult.ok(get_current_async!(socket, key), val)} + end + + Phoenix.Component.assign(socket, new_assigns) + + {:ok, {:error, reason}} -> + new_assigns = + for key <- keys do + {key, AsyncResult.failed(get_current_async!(socket, key), {:error, reason})} + end + + Phoenix.Component.assign(socket, new_assigns) + + {:exit, _reason} = normalized_exit -> + new_assigns = + for key <- keys do + {key, AsyncResult.failed(get_current_async!(socket, key), normalized_exit)} + end + + Phoenix.Component.assign(socket, new_assigns) + end + end + + # handle race of async being canceled and then reassigned + defp prune_current_async(socket, key, ref) do + case get_private_async(socket, key) do + {^ref, _pid, _kind} -> {:ok, update_private_async(socket, &Map.delete(&1, key))} + {_ref, _pid, _kind} -> :error + nil -> :error + end + end + + defp update_private_async(socket, func) do + existing = socket.private[:phoenix_async] || %{} + Phoenix.LiveView.put_private(socket, :phoenix_async, func.(existing)) + end + + defp get_private_async(%Socket{} = socket, key) do + socket.private[:phoenix_async][key] + end + + defp get_current_async!(socket, key) do + # handle case where assign is temporary and needs to be rebuilt + case socket.assigns do + %{^key => %AsyncResult{} = current_async} -> current_async + %{^key => _other} -> AsyncResult.loading(key) + %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" + end + end + + defp to_exit(:throw, reason, stack), do: {:exit, {{:nocatch, reason}, stack}} + defp to_exit(:error, reason, stack), do: {:exit, {reason, stack}} + defp to_exit(:exit, reason, _stack), do: {:exit, reason} + + defp cid(%Socket{} = socket) do + if myself = socket.assigns[:myself], do: myself.cid + end +end diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex new file mode 100644 index 0000000000..26bc31fa5f --- /dev/null +++ b/lib/phoenix_live_view/async_result.ex @@ -0,0 +1,112 @@ +defmodule Phoenix.LiveView.AsyncResult do + @moduledoc ~S''' + Provides a datastructure for tracking the state of an async assign. + + See the `Async Operations` section of the `Phoenix.LiveView` docs for more information. + + ## Fields + + * `:ok?` - When true, indicates the `:result` has been set successfully at least once. + * `:loading` - The current loading state + * `:failed` - The current failed state + * `:result` - The successful result of the async task + ''' + + defstruct ok?: false, + loading: nil, + failed: nil, + result: nil + + alias Phoenix.LiveView.AsyncResult + + @doc """ + Updates the loading state. + + When loading, the failed state will be reset to `nil`. + + ## Examples + + AsyncResult.loading() + AsyncResult.loading(my_async) + AsyncResult.loading(my_async, %{my: :loading_state}) + """ + def loading do + %AsyncResult{loading: true} + end + + def loading(%AsyncResult{} = result) do + %AsyncResult{result | loading: true, failed: nil} + end + + def loading(loading_state) do + %AsyncResult{loading: loading_state, failed: nil} + end + + def loading(%AsyncResult{} = result, loading_state) do + %AsyncResult{result | loading: loading_state, failed: nil} + end + + + @doc """ + Updates the failed state. + + When failed, the loading state will be reset to `nil`. + + ## Examples + + AsyncResult.failed(my_async, {:exit, :boom}) + AsyncResult.failed(my_async, {:error, reason}) + """ + def failed(%AsyncResult{} = result, reason) do + %AsyncResult{result | failed: reason, loading: nil} + end + + @doc """ + Updates the successful result. + + The `:ok?` field will also be set to `true` to indicate this result has + completed successfully at least once, regardless of future state changes. + + When ok'd, the loading and failed state will be reset to `nil`. + + ## Examples + + AsyncResult.ok(my_async, my_result) + """ + def ok(%AsyncResult{} = result, value) do + %AsyncResult{result | failed: nil, loading: nil, ok?: true, result: value} + end + + defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do + alias Phoenix.LiveView.AsyncResult + + def count(%AsyncResult{result: result, ok?: true}), + do: Enumerable.count(result) + + def count(%AsyncResult{}), do: 0 + + def member?(%AsyncResult{result: result, ok?: true}, item) do + Enumerable.member?(result, item) + end + + def member?(%AsyncResult{}, _item), do: false + + def reduce( + %AsyncResult{result: result, ok?: true}, + acc, + fun + ) do + Enumerable.reduce(result, acc, fun) + end + + def reduce(%AsyncResult{}, {_, acc}, _fun), do: {:done, acc} + + def slice(%AsyncResult{result: result, ok?: true}) do + Enumerable.slice(result) + end + + def slice(%AsyncResult{}) do + fn _start, _length, _step -> [] end + end + end +end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index 88691d5a7a..34a2890303 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -4,7 +4,18 @@ defmodule Phoenix.LiveView.Channel do require Logger - alias Phoenix.LiveView.{Socket, Utils, Diff, Upload, UploadConfig, Route, Session, Lifecycle} + alias Phoenix.LiveView.{ + Socket, + Utils, + Diff, + Upload, + UploadConfig, + Route, + Session, + Lifecycle, + Async + } + alias Phoenix.Socket.{Broadcast, Message} @prefix :phoenix @@ -30,6 +41,15 @@ defmodule Phoenix.LiveView.Channel do ) end + def report_async_result(monitor_ref, kind, ref, cid, keys, result) + when is_reference(monitor_ref) and kind in [:assign, :start] and is_reference(ref) do + send(monitor_ref, {@prefix, :async_result, {kind, {ref, cid, keys, result}}}) + end + + def async_pids(lv_pid) do + GenServer.call(lv_pid, {@prefix, :async_pids}) + end + def ping(pid) do GenServer.call(pid, {@prefix, :ping}, :infinity) end @@ -228,6 +248,18 @@ defmodule Phoenix.LiveView.Channel do end end + def handle_info({@prefix, :async_result, {kind, info}}, state) do + {ref, cid, keys, result} = info + + new_state = + write_socket(state, cid, nil, fn socket, maybe_component -> + new_socket = Async.handle_async(socket, maybe_component, kind, keys, ref, result) + {new_socket, {:ok, nil, state}} + end) + + {:noreply, new_state} + end + def handle_info({@prefix, :drop_upload_entries, info}, state) do %{ref: ref, cid: cid, entry_refs: entry_refs} = info @@ -281,6 +313,16 @@ defmodule Phoenix.LiveView.Channel do handle_redirect(state, command, flash, nil) end + def handle_info({{Phoenix.LiveView.Async, keys, cid, kind}, ref, :process, _pid, reason}, state) do + new_state = + write_socket(state, cid, nil, fn socket, component -> + new_socket = Async.handle_trap_exit(socket, component, kind, keys, ref, reason) + {new_socket, {:ok, nil, state}} + end) + + {:noreply, new_state} + end + def handle_info({:phoenix_live_reload, _topic, _changed_file}, %{socket: socket} = state) do Phoenix.CodeReloader.reload(socket.endpoint) @@ -321,6 +363,11 @@ defmodule Phoenix.LiveView.Channel do {:reply, :ok, state} end + def handle_call({@prefix, :async_pids}, _from, state) do + pids = state |> all_asyncs() |> Map.keys() + {:reply, {:ok, pids}, state} + end + def handle_call({@prefix, :fetch_upload_config, name, cid}, _from, state) do read_socket(state, cid, fn socket, _ -> result = @@ -1430,4 +1477,30 @@ defmodule Phoenix.LiveView.Channel do end defp maybe_subscribe_to_live_reload(response), do: response + + defp component_asyncs(state) do + %{components: {components, _ids, _}} = state + + Enum.reduce(components, %{}, fn {cid, {_mod, _id, _assigns, private, _prints}}, acc -> + Map.merge(acc, socket_asyncs(private, cid)) + end) + end + + defp all_asyncs(state) do + %{socket: socket} = state + + socket.private + |> socket_asyncs(nil) + |> Map.merge(component_asyncs(state)) + end + + defp socket_asyncs(private, cid) do + case private do + %{phoenix_async: ref_pids} -> + Enum.into(ref_pids, %{}, fn {key, {ref, pid, kind}} -> {pid, {key, ref, cid, kind}} end) + + %{} -> + %{} + end + end end diff --git a/lib/phoenix_live_view/test/client_proxy.ex b/lib/phoenix_live_view/test/client_proxy.ex index 08a2db78a4..c6325e1bb2 100644 --- a/lib/phoenix_live_view/test/client_proxy.ex +++ b/lib/phoenix_live_view/test/client_proxy.ex @@ -526,6 +526,12 @@ defmodule Phoenix.LiveViewTest.ClientProxy do {:noreply, state} end + def handle_call({:async_pids, topic_or_element}, _from, state) do + topic = proxy_topic(topic_or_element) + %{pid: pid} = fetch_view_by_topic!(state, topic) + {:reply, Phoenix.LiveView.Channel.async_pids(pid), state} + end + def handle_call({:render_event, topic_or_element, type, value}, from, state) do topic = proxy_topic(topic_or_element) %{pid: pid} = fetch_view_by_topic!(state, topic) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index df415bfd24..793c91e68c 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -468,8 +468,7 @@ defmodule Phoenix.LiveViewTest do def __render_component__(endpoint, %{module: component}, assigns, opts) do socket = %Socket{endpoint: endpoint, router: opts[:router]} - assigns = - Map.new(assigns) + assigns = Map.new(assigns) # TODO: Make the ID required once we support only stateful module components as live_component mount_assigns = if assigns[:id], do: %{myself: %Phoenix.LiveComponent.CID{cid: -1}}, else: %{} @@ -922,6 +921,54 @@ defmodule Phoenix.LiveViewTest do call(view, {:render_event, {proxy_topic(view), to_string(event), view.target}, type, value}) end + @doc """ + Awaits all current `assign_async` and `start_async` for a given LiveView or element. + + It renders the LiveView or Element once complete and returns the result. + By default, the timeout is 100ms, but a custom time may be passed to override. + + ## Examples + + {:ok, lv, html} = live(conn, "/path") + assert html =~ "loading data..." + assert render_async(lv) =~ "data loaded!" + """ + def render_async( + view_or_element, + timeout \\ Application.fetch_env!(:ex_unit, :assert_receive_timeout) + ) do + pids = + case view_or_element do + %View{} = view -> call(view, {:async_pids, {proxy_topic(view), nil, nil}}) + %Element{} = element -> call(element, {:async_pids, element}) + end + + timeout_ref = make_ref() + Process.send_after(self(), {timeout_ref, :timeout}, timeout) + + pids + |> Enum.map(&Process.monitor(&1)) + |> Enum.each(fn ref -> + receive do + {^timeout_ref, :timeout} -> + raise RuntimeError, "expected async processes to finish within #{timeout}ms" + + {:DOWN, ^ref, :process, _pid, _reason} -> + :ok + end + end) + + unless Process.cancel_timer(timeout_ref) do + receive do + {^timeout_ref, :timeout} -> :noop + after + 0 -> :noop + end + end + + render(view_or_element) + end + @doc """ Simulates a `live_patch` to the given `path` and returns the rendered result. """ diff --git a/test/phoenix_live_view/async_result_test.exs b/test/phoenix_live_view/async_result_test.exs new file mode 100644 index 0000000000..e53a0f4c9c --- /dev/null +++ b/test/phoenix_live_view/async_result_test.exs @@ -0,0 +1,25 @@ +defmodule Phoenix.LiveView.AsyncResultTest do + use ExUnit.Case, async: true + + alias Phoenix.LiveView.AsyncResult + + test "ok" do + async = AsyncResult.loading() + assert Enum.sum(AsyncResult.ok(async, [1, 1, 1])) == 3 + assert Enum.count(AsyncResult.ok(async, [1, 2, 3])) == 3 + assert Enum.map(AsyncResult.ok(async, [1, 2, 3]), &(&1 * 2)) == [2, 4, 6] + end + + test "not ok" do + async = AsyncResult.loading() + + # loading + assert Enum.sum(async) == 0 + assert Enum.map(async, & &1) == [] + + # failed + failed = AsyncResult.failed(async, {:exit, :boom}) + assert Enum.sum(failed) == 0 + assert Enum.map(failed, & &1) == [] + end +end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs new file mode 100644 index 0000000000..c7c8e12d28 --- /dev/null +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -0,0 +1,177 @@ +defmodule Phoenix.LiveView.AssignAsyncTest do + use ExUnit.Case, async: true + import Phoenix.ConnTest + + import Phoenix.LiveViewTest + alias Phoenix.LiveViewTest.Endpoint + + @endpoint Endpoint + + setup do + {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} + end + + describe "LiveView assign_async" do + test "bad return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=bad_return") + + assert render_async(lv) =~ + "{:exit, {%ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:data] or {:error, reason}, got: 123\\n"}" + + assert render(lv) + end + + test "missing known key", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=bad_ok") + + assert render_async(lv) =~ + "expected assign_async to return map of assigns for all keys\\nin [:data]" + + assert render(lv) + end + + test "valid return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=ok") + assert render_async(lv) =~ "data: 123" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=raise") + + assert render_async(lv) =~ "{:exit, {%RuntimeError{message: "boom"}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=exit") + + assert render_async(lv) =~ "{:exit, :boom}" + assert render(lv) + end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lv_exit") + Process.unlink(lv.pid) + lv_ref = Process.monitor(lv.pid) + async_ref = Process.monitor(Process.whereis(:lv_exit)) + send(lv.pid, :boom) + + assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 1000 + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=cancel") + Process.unlink(lv.pid) + async_ref = Process.monitor(Process.whereis(:cancel)) + send(lv.pid, :cancel) + + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 + + assert render(lv) =~ ":cancel" + + send(lv.pid, :renew_canceled) + + assert render(lv) =~ "data loading..." + assert render_async(lv, 200) =~ "data: 123" + end + + test "enum", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=enum") + + html = render_async(lv, 200) + assert html =~ "data: [1, 2, 3]" + assert html =~ "
1
2
3
" + end + + test "trapping exits", %{conn: conn} do + Process.register(self(), :trap_exit_test) + {:ok, lv, _html} = live(conn, "/assign_async?test=trap_exit") + + assert render_async(lv, 200) =~ "{:exit, :boom}" + assert render(lv) + assert_receive {:exit, _pid, :boom}, 1000 + end + end + + describe "LiveComponent assign_async" do + test "bad return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_bad_return") + + assert render_async(lv) =~ + "exit: {%ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:lc_data] or {:error, reason}, got: 123\\n"}" + + assert render(lv) + end + + test "missing known key", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_bad_ok") + + assert render_async(lv) =~ + "expected assign_async to return map of assigns for all keys\\nin [:lc_data]" + + assert render(lv) + end + + test "valid return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_ok") + assert render_async(lv) =~ "lc_data: 123" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_raise") + + assert render_async(lv) =~ "exit: {%RuntimeError{message: "boom"}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_exit") + + assert render_async(lv) =~ "exit: :boom" + assert render(lv) + end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_lv_exit") + Process.unlink(lv.pid) + lv_ref = Process.monitor(lv.pid) + async_ref = Process.monitor(Process.whereis(:lc_exit)) + send(lv.pid, :boom) + + assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 1000 + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_cancel") + Process.unlink(lv.pid) + async_ref = Process.monitor(Process.whereis(:lc_cancel)) + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, + id: "lc", + action: :cancel + ) + + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 + + assert render(lv) =~ "exit: {:shutdown, :cancel}" + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, + id: "lc", + action: :renew_canceled + ) + + assert render(lv) =~ "lc_data loading..." + assert render_async(lv, 200) =~ "lc_data: 123" + end + + test "enum", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_enum") + + html = render_async(lv, 200) + assert html =~ "lc_data: [4, 5, 6]" + assert html =~ "
4
5
6
" + end + end +end diff --git a/test/phoenix_live_view/integrations/hooks_test.exs b/test/phoenix_live_view/integrations/hooks_test.exs index f77a87b3e7..55859ab5a4 100644 --- a/test/phoenix_live_view/integrations/hooks_test.exs +++ b/test/phoenix_live_view/integrations/hooks_test.exs @@ -123,6 +123,9 @@ defmodule Phoenix.LiveView.HooksTest do {:halt, %{}, socket} end) + %{proxy: {_ref, _topic, proxy_pid}} = lv + Process.unlink(proxy_pid) + assert ExUnit.CaptureLog.capture_log(fn -> send(lv.pid, :boom) ref = Process.monitor(lv.pid) diff --git a/test/phoenix_live_view/integrations/start_async_test.exs b/test/phoenix_live_view/integrations/start_async_test.exs new file mode 100644 index 0000000000..90fb7ee744 --- /dev/null +++ b/test/phoenix_live_view/integrations/start_async_test.exs @@ -0,0 +1,127 @@ +defmodule Phoenix.LiveView.StartAsyncTest do + use ExUnit.Case, async: true + import Phoenix.ConnTest + + import Phoenix.LiveViewTest + alias Phoenix.LiveViewTest.Endpoint + + @endpoint Endpoint + + setup do + {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} + end + + describe "LiveView start_async" do + test "ok task", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=ok") + + assert render_async(lv) =~ "result: :good" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=raise") + + assert render_async(lv) =~ "result: {:exit, %RuntimeError{message: "boom"}}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=exit") + + assert render_async(lv) =~ "result: {:exit, :boom}" + assert render(lv) + end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lv_exit") + Process.unlink(lv.pid) + lv_ref = Process.monitor(lv.pid) + async_ref = Process.monitor(Process.whereis(:start_async_exit)) + send(lv.pid, :boom) + + assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 1000 + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=cancel") + Process.unlink(lv.pid) + async_ref = Process.monitor(Process.whereis(:start_async_cancel)) + send(lv.pid, :cancel) + + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 + + assert render(lv) =~ "result: :loading" + + send(lv.pid, :renew_canceled) + + assert render(lv) =~ "result: :loading" + assert render_async(lv, 200) =~ "result: :renewed" + end + + test "trapping exits", %{conn: conn} do + Process.register(self(), :start_async_trap_exit_test) + {:ok, lv, _html} = live(conn, "/start_async?test=trap_exit") + + assert render_async(lv, 200) =~ "result: :loading" + assert render(lv) + assert_receive {:exit, _pid, :boom}, 1000 + end + end + + describe "LiveComponent start_async" do + test "ok task", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lc_ok") + + assert render_async(lv) =~ "lc: :good" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lc_raise") + + assert render_async(lv) =~ "lc: {:exit, %RuntimeError{message: "boom"}}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lc_exit") + + assert render_async(lv) =~ "lc: {:exit, :boom}" + assert render(lv) + end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lc_lv_exit") + Process.unlink(lv.pid) + lv_ref = Process.monitor(lv.pid) + async_ref = Process.monitor(Process.whereis(:start_async_exit)) + send(lv.pid, :boom) + + assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 1000 + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/start_async?test=lc_cancel") + Process.unlink(lv.pid) + async_ref = Process.monitor(Process.whereis(:start_async_cancel)) + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.StartAsyncLive.LC, + id: "lc", + action: :cancel + ) + + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}} + + assert render(lv) =~ "lc: :loading" + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.StartAsyncLive.LC, + id: "lc", + action: :renew_canceled + ) + + assert render(lv) =~ "lc: :loading" + assert render_async(lv, 200) =~ "lc: :renewed" + end + end +end diff --git a/test/phoenix_live_view/upload/channel_test.exs b/test/phoenix_live_view/upload/channel_test.exs index 83b586273b..4458a756f0 100644 --- a/test/phoenix_live_view/upload/channel_test.exs +++ b/test/phoenix_live_view/upload/channel_test.exs @@ -225,7 +225,7 @@ defmodule Phoenix.LiveView.UploadChannelTest do ) == {:error, %{limit: 100, reason: :file_size_limit_exceeded}} - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 end @tag allow: [accept: :any, max_file_size: 100, chunk_timeout: 500] @@ -398,8 +398,8 @@ defmodule Phoenix.LiveView.UploadChannelTest do end) # Wait for the UploadClient and UploadChannel to shutdown - assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}} - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 assert_receive {:file, tmp_path, "foo.jpeg", "123"} # synchronize with LV and Plug.Upload to ensure they have processed DOWN assert render(lv) @@ -427,7 +427,7 @@ defmodule Phoenix.LiveView.UploadChannelTest do {:reply, :ok, socket} end) - assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 assert_receive {:file, tmp_path, "foo.jpeg", "123"} # synchronize with LV to ensure it has processed DOWN assert render(lv) @@ -539,8 +539,8 @@ defmodule Phoenix.LiveView.UploadChannelTest do assert_receive {:results, [{:consumed, tmp_path}]} assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} - assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}} - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 # synchronize with LV to ensure it has processed DOWN assert render(lv) # synchronize with Plug.Upload to ensure it has processed DOWN @@ -594,8 +594,8 @@ defmodule Phoenix.LiveView.UploadChannelTest do assert_receive {:result, {:consumed, tmp_path}} assert_receive {:file, ^tmp_path, "foo.jpeg", "123"} - assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}} - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^avatar_pid, {:shutdown, :closed}}, 1000 + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 # synchronize with LV to ensure it has processed DOWN assert render(lv) # synchronize with Plug.Upload to ensure it has processed DOWN @@ -635,7 +635,7 @@ defmodule Phoenix.LiveView.UploadChannelTest do {:reply, :ok, Phoenix.LiveView.cancel_upload(socket, :avatar, ref)} end) - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 assert UploadLive.run(lv, fn socket -> {:reply, Phoenix.LiveView.uploaded_entries(socket, :avatar), socket} @@ -830,7 +830,7 @@ defmodule Phoenix.LiveView.UploadChannelTest do refute render(lv) =~ "myfile1.jpeg" - assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}} + assert_receive {:DOWN, _ref, :process, ^channel_pid, {:shutdown, :closed}}, 1000 # retry with new component GenServer.call(lv.pid, {:uploads, 1}) diff --git a/test/phoenix_live_view_test.exs b/test/phoenix_live_view_test.exs index 38a772eea9..e22ab776c5 100644 --- a/test/phoenix_live_view_test.exs +++ b/test/phoenix_live_view_test.exs @@ -288,4 +288,17 @@ defmodule Phoenix.LiveViewUnitTest do {:live, :patch, %{kind: :push, to: "/counter/123"}} end end + + describe "put_private" do + test "assigns private keys" do + assert @socket.private[:hello] == nil + assert put_private(@socket, :hello, "world").private[:hello] == "world" + end + + test "disallows reserved keys" do + assert_raise ArgumentError, ~r/reserved/, fn -> + put_private(@socket, :assign_new, "boom") + end + end + end end diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 172c78122a..6029eebb5a 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -336,3 +336,370 @@ defmodule Phoenix.LiveViewTest.ClassListLive do def render(assigns), do: ~H|Some content| end + +defmodule Phoenix.LiveViewTest.AssignAsyncLive do + use Phoenix.LiveView + + on_mount({__MODULE__, :defaults}) + + def on_mount(:defaults, _params, _session, socket) do + {:cont, assign(socket, enum: false, lc: false)} + end + + def render(assigns) do + ~H""" + <.live_component :if={@lc} module={Phoenix.LiveViewTest.AssignAsyncLive.LC} test={@lc} id="lc" /> + +
data loading...
+
no data found
+
data: <%= inspect(@data.result) %>
+
<%= inspect(@data.failed) %>
+ + <%= if @enum do %> +
<%= i %>
+ <% end %> + """ + end + + def mount(%{"test" => "lc_" <> lc_test}, _session, socket) do + {:ok, + socket + |> assign(lc: lc_test) + |> assign_async(:data, fn -> {:ok, %{data: :live_component}} end)} + end + + def mount(%{"test" => "bad_return"}, _session, socket) do + {:ok, assign_async(socket, :data, fn -> 123 end)} + end + + def mount(%{"test" => "bad_ok"}, _session, socket) do + {:ok, assign_async(socket, :data, fn -> {:ok, %{bad: 123}} end)} + end + + def mount(%{"test" => "ok"}, _session, socket) do + {:ok, assign_async(socket, :data, fn -> {:ok, %{data: 123}} end)} + end + + def mount(%{"test" => "raise"}, _session, socket) do + {:ok, assign_async(socket, :data, fn -> raise("boom") end)} + end + + def mount(%{"test" => "exit"}, _session, socket) do + {:ok, assign_async(socket, :data, fn -> exit(:boom) end)} + end + + def mount(%{"test" => "lv_exit"}, _session, socket) do + {:ok, + assign_async(socket, :data, fn -> + Process.register(self(), :lv_exit) + Process.sleep(:infinity) + end)} + end + + def mount(%{"test" => "cancel"}, _session, socket) do + {:ok, + assign_async(socket, :data, fn -> + Process.register(self(), :cancel) + Process.sleep(:infinity) + end)} + end + + def mount(%{"test" => "trap_exit"}, _session, socket) do + Process.flag(:trap_exit, true) + + {:ok, + assign_async(socket, :data, fn -> + spawn_link(fn -> exit(:boom) end) + Process.sleep(100) + {:ok, %{data: 0}} + end)} + end + + def mount(%{"test" => "enum"}, _session, socket) do + {:ok, + socket |> assign(enum: true) |> assign_async(:data, fn -> {:ok, %{data: [1, 2, 3]}} end)} + end + + def handle_info(:boom, _socket), do: exit(:boom) + + def handle_info(:cancel, socket) do + {:noreply, cancel_async(socket, socket.assigns.data)} + end + + def handle_info({:EXIT, pid, reason}, socket) do + send(:trap_exit_test, {:exit, pid, reason}) + {:noreply, socket} + end + + def handle_info(:renew_canceled, socket) do + {:noreply, + assign_async(socket, :data, fn -> + Process.sleep(100) + {:ok, %{data: 123}} + end)} + end +end + +defmodule Phoenix.LiveViewTest.AssignAsyncLive.LC do + use Phoenix.LiveComponent + + def render(assigns) do + ~H""" +
+ <%= if @enum do %> +
<%= i %>
+ <% end %> + <.async_result :let={data} assign={@lc_data}> + <:loading>lc_data loading... + <:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> + + lc_data: <%= inspect(data) %> + +
+ """ + end + + def mount(socket) do + {:ok, assign(socket, enum: false)} + end + + def update(%{test: "bad_return"}, socket) do + {:ok, assign_async(socket, :lc_data, fn -> 123 end)} + end + + def update(%{test: "bad_ok"}, socket) do + {:ok, assign_async(socket, :lc_data, fn -> {:ok, %{bad: 123}} end)} + end + + def update(%{test: "ok"}, socket) do + {:ok, assign_async(socket, :lc_data, fn -> {:ok, %{lc_data: 123}} end)} + end + + def update(%{test: "raise"}, socket) do + {:ok, assign_async(socket, :lc_data, fn -> raise("boom") end)} + end + + def update(%{test: "exit"}, socket) do + {:ok, assign_async(socket, :lc_data, fn -> exit(:boom) end)} + end + + def update(%{test: "lv_exit"}, socket) do + {:ok, + assign_async(socket, :lc_data, fn -> + Process.register(self(), :lc_exit) + Process.sleep(:infinity) + end)} + end + + def update(%{test: "cancel"}, socket) do + {:ok, + assign_async(socket, :lc_data, fn -> + Process.register(self(), :lc_cancel) + Process.sleep(:infinity) + end)} + end + + def update(%{test: "enum"}, socket) do + {:ok, + socket + |> assign(enum: true) + |> assign_async(:lc_data, fn -> {:ok, %{lc_data: [4, 5, 6]}} end)} + end + + def update(%{action: :boom}, _socket), do: exit(:boom) + + def update(%{action: :cancel}, socket) do + {:ok, cancel_async(socket, socket.assigns.lc_data)} + end + + def update(%{action: :renew_canceled}, socket) do + {:ok, + assign_async(socket, :lc_data, fn -> + Process.sleep(100) + {:ok, %{lc_data: 123}} + end)} + end +end + +defmodule Phoenix.LiveViewTest.StartAsyncLive do + use Phoenix.LiveView + + on_mount({__MODULE__, :defaults}) + + def on_mount(:defaults, _params, _session, socket) do + {:cont, assign(socket, lc: false)} + end + + def render(assigns) do + ~H""" + <.live_component :if={@lc} module={Phoenix.LiveViewTest.StartAsyncLive.LC} test={@lc} id="lc" /> + result: <%= inspect(@result) %> + """ + end + + def mount(%{"test" => "lc_" <> lc_test}, _session, socket) do + {:ok, assign(socket, lc: lc_test, result: :loading)} + end + + def mount(%{"test" => "ok"}, _session, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> :good end)} + end + + def mount(%{"test" => "raise"}, _session, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> raise("boom") end)} + end + + def mount(%{"test" => "exit"}, _session, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> exit(:boom) end)} + end + + def mount(%{"test" => "lv_exit"}, _session, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> + Process.register(self(), :start_async_exit) + Process.sleep(:infinity) + end)} + end + + def mount(%{"test" => "cancel"}, _session, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> + Process.register(self(), :start_async_cancel) + Process.sleep(:infinity) + end)} + end + + def mount(%{"test" => "trap_exit"}, _session, socket) do + Process.flag(:trap_exit, true) + + {:ok, + socket + |> assign(result: :loading) + |> assign_async(:result_task, fn -> + spawn_link(fn -> exit(:boom) end) + Process.sleep(100) + :good + end)} + end + + def handle_async(:result_task, {:ok, result}, socket) do + {:noreply, assign(socket, result: result)} + end + + def handle_async(:result_task, {:exit, {error, [_ | _] = _stack}}, socket) do + {:noreply, assign(socket, result: {:exit, error})} + end + + def handle_async(:result_task, {:exit, reason}, socket) do + {:noreply, assign(socket, result: {:exit, reason})} + end + + def handle_info(:boom, _socket), do: exit(:boom) + + def handle_info(:cancel, socket) do + {:noreply, cancel_async(socket, :result_task)} + end + + def handle_info(:renew_canceled, socket) do + {:noreply, + start_async(socket, :result_task, fn -> + Process.sleep(100) + :renewed + end)} + end + + def handle_info({:EXIT, pid, reason}, socket) do + send(:start_async_trap_exit_test, {:exit, pid, reason}) + {:noreply, socket} + end +end + +defmodule Phoenix.LiveViewTest.StartAsyncLive.LC do + use Phoenix.LiveComponent + + def render(assigns) do + ~H""" +
+ lc: <%= inspect(@result) %> +
+ """ + end + + def update(%{test: "ok"}, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> :good end)} + end + + def update(%{test: "raise"}, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> raise("boom") end)} + end + + def update(%{test: "exit"}, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> exit(:boom) end)} + end + + def update(%{test: "lv_exit"}, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> + Process.register(self(), :start_async_exit) + Process.sleep(:infinity) + end)} + end + + def update(%{test: "cancel"}, socket) do + {:ok, + socket + |> assign(result: :loading) + |> start_async(:result_task, fn -> + Process.register(self(), :start_async_cancel) + Process.sleep(:infinity) + end)} + end + + def update(%{action: :cancel}, socket) do + {:ok, cancel_async(socket, :result_task)} + end + + def update(%{action: :renew_canceled}, socket) do + {:ok, + start_async(socket, :result_task, fn -> + Process.sleep(100) + :renewed + end)} + end + + def handle_async(:result_task, {:ok, result}, socket) do + {:noreply, assign(socket, result: result)} + end + + def handle_async(:result_task, {:exit, {error, [_ | _] = _stack}}, socket) do + {:noreply, assign(socket, result: {:exit, error})} + end + + def handle_async(:result_task, {:exit, reason}, socket) do + {:noreply, assign(socket, result: {:exit, reason})} + end +end diff --git a/test/support/router.ex b/test/support/router.ex index 895254013c..3c8babfa25 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -45,6 +45,8 @@ defmodule Phoenix.LiveViewTest.Router do live "/log-disabled", WithLogDisabled live "/errors", ErrorsLive live "/live-reload", ReloadLive + live "/assign_async", AssignAsyncLive + live "/start_async", StartAsyncLive # controller test get "/controller/:type", Controller, :incoming