diff --git a/lib/phoenix_live_view/application.ex b/lib/phoenix_live_view/application.ex index e888ed6828..fd62f22d19 100644 --- a/lib/phoenix_live_view/application.ex +++ b/lib/phoenix_live_view/application.ex @@ -6,6 +6,11 @@ defmodule Phoenix.LiveView.Application do @impl true def start(_type, _args) do Phoenix.LiveView.Logger.install() - Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) + + children = [ + {Task.Supervisor, name: Phoenix.LiveView.TaskSup} + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) end end diff --git a/lib/phoenix_live_view/async_assign.ex b/lib/phoenix_live_view/async_assign.ex new file mode 100644 index 0000000000..3b20d9d3b8 --- /dev/null +++ b/lib/phoenix_live_view/async_assign.ex @@ -0,0 +1,185 @@ +defmodule Phoenix.LiveView.AsyncAssign do + @moduledoc ~S''' + Adds async_assign functionality to LiveViews. + + ## Examples + + defmodule MyLive do + + def render(assigns) do + + ~H""" +
Loading organization...
+
You don't have an org yet
+
there was an error loading the organization
+ + <.async_result let={org} item={@async.org}> + <:loading>Loading organization... + <:empty>You don't have an organization yet + <:error>there was an error loading the organization + <%= org.name %> + <.async_result> + +
...
+ """ + end + + def mount(%{"slug" => slug}, _, socket) do + {:ok, + socket + |> assign(:foo, "bar) + |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end) + |> assign_async(:profile, [:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + end + end + ''' + + defstruct name: nil, + ref: nil, + pid: nil, + keys: [], + loading?: false, + error: nil, + result: nil, + canceled?: false + + alias Phoenix.LiveView.AsyncAssign + + @doc """ + TODO + """ + def assign_async(%Phoenix.LiveView.Socket{} = socket, name, func) do + assign_async(socket, name, [name], func) + end + + def assign_async(%Phoenix.LiveView.Socket{} = socket, name, keys, func) + when is_atom(name) and is_list(keys) and is_function(func, 0) do + socket = cancel_existing(socket, name) + base_async = %AsyncAssign{name: name, keys: keys, loading?: true} + + async_assign = + if Phoenix.LiveView.connected?(socket) do + lv_pid = self() + ref = make_ref() + cid = if myself = socket.assigns[:myself], do: myself.cid + + {:ok, pid} = + Task.start_link(fn -> + do_async(lv_pid, cid, %AsyncAssign{base_async | pid: self(), ref: ref}, func) + end) + + %AsyncAssign{base_async | pid: pid, ref: ref} + else + base_async + end + + Enum.reduce(keys, socket, fn key, acc -> update_async(acc, key, async_assign) end) + end + + @doc """ + TODO + """ + def cancel_async(%Phoenix.LiveView.Socket{} = socket, name) do + case get(socket, name) do + %AsyncAssign{loading?: false} -> + socket + + %AsyncAssign{canceled?: false, pid: pid} = existing -> + Process.unlink(pid) + Process.exit(pid, :kill) + async = %AsyncAssign{existing | loading?: false, error: nil, canceled?: true} + update_async(socket, name, async) + + nil -> + raise ArgumentError, + "no async assign #{inspect(name)} previously assigned with assign_async" + end + end + + defp do_async(lv_pid, cid, %AsyncAssign{} = async_assign, func) do + %AsyncAssign{keys: known_keys, name: name, ref: ref} = async_assign + + try do + case func.() do + {:ok, %{} = results} -> + if Map.keys(results) -- known_keys == [] do + Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket -> + write_current_async(socket, name, ref, fn %AsyncAssign{} = current -> + Enum.reduce(results, socket, fn {key, result}, acc -> + async = %AsyncAssign{current | result: result, loading?: false} + update_async(acc, key, async) + end) + end) + end) + else + raise ArgumentError, """ + expected assign_async to return map of + assigns for all keys in #{inspect(known_keys)}, but got: #{inspect(results)} + """ + end + + {:error, reason} -> + Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket -> + write_current_async(socket, name, ref, fn %AsyncAssign{} = current -> + Enum.reduce(known_keys, socket, fn key, acc -> + async = %AsyncAssign{current | result: nil, loading?: false, error: reason} + update_async(acc, key, async) + end) + end) + end) + + other -> + raise ArgumentError, """ + expected assign_async to return {:ok, map} of + assigns for #{inspect(known_keys)} or {:error, reason}, got: #{inspect(other)} + """ + end + catch + kind, reason -> + Process.unlink(lv_pid) + + Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket -> + write_current_async(socket, name, ref, fn %AsyncAssign{} = current -> + Enum.reduce(known_keys, socket, fn key, acc -> + async = %AsyncAssign{current | loading?: false, error: {kind, reason}} + update_async(acc, key, async) + end) + end) + end) + + :erlang.raise(kind, reason, __STACKTRACE__) + end + end + + defp update_async(socket, key, %AsyncAssign{} = new_async) do + socket + |> ensure_async() + |> Phoenix.Component.update(:async, fn async_map -> + Map.put(async_map, key, new_async) + end) + end + + defp ensure_async(socket) do + Phoenix.Component.assign_new(socket, :async, fn -> %{} end) + end + + defp get(%Phoenix.LiveView.Socket{} = socket, name) do + socket.assigns[:async] && Map.get(socket.assigns.async, name) + end + + # handle race of async being canceled and then reassigned + defp write_current_async(socket, name, ref, func) do + case get(socket, name) do + %AsyncAssign{ref: ^ref} = async_assign -> func.(async_assign) + %AsyncAssign{ref: _ref} -> socket + end + end + + defp cancel_existing(socket, name) do + if get(socket, name) do + cancel_async(socket, name) + else + socket + end + end +end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index 33cbb2c260..5728659a44 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -30,6 +30,14 @@ defmodule Phoenix.LiveView.Channel do ) end + def write_socket(lv_pid, cid, func) when is_function(func, 1) do + GenServer.call(lv_pid, {@prefix, :write_socket, cid, func}) + 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 @@ -282,6 +290,31 @@ defmodule Phoenix.LiveView.Channel do {:reply, :ok, state} end + def handle_call({@prefix, :async_pids}, _from, state) do + %{socket: socket} = state + lv_pids = get_async_pids(socket.assigns) + + component_pids = + state + |> component_assigns() + |> Enum.flat_map(fn + {_cid, %{async: %{}} = assigns} -> get_async_pids(assigns) + {_cid, %{}} -> [] + end) + + {:reply, {:ok, lv_pids ++ component_pids}, state} + end + + def handle_call({@prefix, :write_socket, cid, func}, _from, state) do + new_state = + write_socket(state, cid, nil, fn socket, _ -> + %Phoenix.LiveView.Socket{} = new_socket = func.(socket) + {new_socket, {:ok, nil, state}} + end) + + {:reply, :ok, new_state} + end + def handle_call({@prefix, :fetch_upload_config, name, cid}, _from, state) do read_socket(state, cid, fn socket, _ -> result = @@ -1391,4 +1424,16 @@ defmodule Phoenix.LiveView.Channel do end defp maybe_subscribe_to_live_reload(response), do: response + + defp component_assigns(state) do + %{components: {components, _ids, _}} = state + + Enum.into(components, %{}, fn {cid, {_mod, _id, assigns, _private, _prints}} -> + {cid, assigns} + end) + end + + defp get_async_pids(assigns) do + Enum.map(assigns[:async] || %{}, fn {_, %Phoenix.LiveView.AsyncAssign{pid: pid}} -> pid 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..1dd0dd516e 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,33 @@ defmodule Phoenix.LiveViewTest do call(view, {:render_event, {proxy_topic(view), to_string(event), view.target}, type, value}) end + @doc """ + TODO + """ + def await_async(view_or_element, timeout \\ 100) 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 + + task = + Task.async(fn -> + pids + |> Enum.map(&Process.monitor(&1)) + |> Enum.each(fn ref -> + receive do + {:DOWN, ^ref, :process, _pid, _reason} -> :ok + end + end) + end) + + case Task.yield(task, timeout) || Task.ignore(task) do + {:ok, _} -> :ok + nil -> raise RuntimeError, "expected async processes to finish within #{timeout}ms" + end + end + @doc """ Simulates a `live_patch` to the given `path` and returns the rendered result. """ 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..2b90f85d77 --- /dev/null +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -0,0 +1,48 @@ +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 "assign_async" do + test "bad return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=bad_return") + + await_async(lv) + assert render(lv) =~ + "error: {:error, %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, "/async?test=bad_ok") + + await_async(lv) + assert render(lv) =~ + "expected assign_async to return map of\\nassigns for all keys in [:data]" + + assert render(lv) + end + + test "valid return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=ok") + await_async(lv) + assert render(lv) =~ "data: 123" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=raise") + + await_async(lv) + assert render(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" + end + end +end diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 172c78122a..139ac5dbf2 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -336,3 +336,48 @@ defmodule Phoenix.LiveViewTest.ClassListLive do def render(assigns), do: ~H|Some content| end + +defmodule Phoenix.LiveViewTest.AsyncLive do + use Phoenix.LiveView + import Phoenix.LiveView.AsyncAssign + + defmodule LC do + use Phoenix.LiveComponent + + def render(assigns) do + ~H""" +
<%= inspect(@async.data) %>
+ """ + end + + def update(_assigns, socket) do + {:ok, assign_async(socket, :data, fn -> {:ok, %{data: 123}} end)} + end + end + + def render(assigns) do + ~H""" + <.live_component module={LC} id="lc" /> +
data loading...
+
no data found
+
data: <%= inspect(@async.data.result) %>
+
error: <%= inspect(err) %>
+ """ + 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 +end diff --git a/test/support/router.ex b/test/support/router.ex index 895254013c..fc56c3bab7 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -45,6 +45,7 @@ defmodule Phoenix.LiveViewTest.Router do live "/log-disabled", WithLogDisabled live "/errors", ErrorsLive live "/live-reload", ReloadLive + live "/async", AsyncLive # controller test get "/controller/:type", Controller, :incoming