From 36ff163dac3fcb0b1d04faeb4422c42ab67f5f8b Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 4 Aug 2023 16:51:47 -0400 Subject: [PATCH 01/63] WIP --- lib/phoenix_live_view/application.ex | 7 +- lib/phoenix_live_view/async_assign.ex | 185 ++++++++++++++++++ lib/phoenix_live_view/channel.ex | 45 +++++ lib/phoenix_live_view/test/client_proxy.ex | 6 + lib/phoenix_live_view/test/live_view_test.ex | 30 ++- .../integrations/assign_async_test.exs | 48 +++++ test/support/live_views/general.ex | 45 +++++ test/support/router.ex | 1 + 8 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 lib/phoenix_live_view/async_assign.ex create mode 100644 test/phoenix_live_view/integrations/assign_async_test.exs 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 From 706b657df253f5d83e9b8e20cc82c99ae6ec909c Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 4 Aug 2023 16:52:11 -0400 Subject: [PATCH 02/63] Don't need this --- lib/phoenix_live_view/application.ex | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/phoenix_live_view/application.ex b/lib/phoenix_live_view/application.ex index fd62f22d19..e888ed6828 100644 --- a/lib/phoenix_live_view/application.ex +++ b/lib/phoenix_live_view/application.ex @@ -6,11 +6,6 @@ defmodule Phoenix.LiveView.Application do @impl true def start(_type, _args) do Phoenix.LiveView.Logger.install() - - children = [ - {Task.Supervisor, name: Phoenix.LiveView.TaskSup} - ] - - Supervisor.start_link(children, strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) + Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor) end end From fd1c4e7cebb9e9a1777b15ff9eb6d5f4d762304a Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Sat, 5 Aug 2023 18:03:17 -0400 Subject: [PATCH 03/63] render_async --- lib/phoenix_live_view/test/live_view_test.ex | 4 +++- .../integrations/assign_async_test.exs | 12 ++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index 1dd0dd516e..edd8dec2a1 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -924,7 +924,7 @@ defmodule Phoenix.LiveViewTest do @doc """ TODO """ - def await_async(view_or_element, timeout \\ 100) do + def render_async(view_or_element, timeout \\ 100) do pids = case view_or_element do %View{} = view -> call(view, {:async_pids, {proxy_topic(view), nil, nil}}) @@ -946,6 +946,8 @@ defmodule Phoenix.LiveViewTest do {:ok, _} -> :ok nil -> raise RuntimeError, "expected async processes to finish within #{timeout}ms" end + + render(view_or_element) end @doc """ diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 2b90f85d77..0b4c3d941f 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -15,8 +15,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "bad return", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=bad_return") - await_async(lv) - assert render(lv) =~ + assert render_async(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) @@ -25,8 +24,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "missing known key", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=bad_ok") - await_async(lv) - assert render(lv) =~ + assert render_async(lv) =~ "expected assign_async to return map of\\nassigns for all keys in [:data]" assert render(lv) @@ -34,15 +32,13 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "valid return", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=ok") - await_async(lv) - assert render(lv) =~ "data: 123" + assert render_async(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"}}" + assert render_async(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" end end end From b6a30b9d7ba84fba9b33d7f9e34de1b93ba0f89f Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Sat, 5 Aug 2023 18:36:46 -0400 Subject: [PATCH 04/63] Exit does not bring down lv --- test/phoenix_live_view/integrations/assign_async_test.exs | 8 ++++++++ test/support/live_views/general.ex | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 0b4c3d941f..93821b9204 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -39,6 +39,14 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, lv, _html} = live(conn, "/async?test=raise") assert render_async(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=exit") + + assert render_async(lv) =~ "error: {:exit, :boom}" + assert render(lv) end end end diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 139ac5dbf2..67a6dac07f 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -380,4 +380,8 @@ defmodule Phoenix.LiveViewTest.AsyncLive do 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 end From b75f7d8f0a5c89e4b4cf1c7d2f8244b66606702d Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Sun, 6 Aug 2023 13:52:47 -0400 Subject: [PATCH 05/63] Test cancel_async --- .../integrations/assign_async_test.exs | 27 ++++++++++++++++++ test/support/live_views/general.ex | 28 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 93821b9204..d8073abe05 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -48,5 +48,32 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert render_async(lv) =~ "error: {:exit, :boom}" assert render(lv) end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/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} + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom} + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/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, :killed} + + assert render(lv) =~ "data canceled" + + send(lv.pid, :renew_canceled) + + assert render(lv) =~ "data loading..." + assert render_async(lv, 200) =~ "data: 123" + end end end diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 67a6dac07f..69ed0dc589 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -359,6 +359,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do ~H""" <.live_component module={LC} id="lc" />
data loading...
+
data canceled
no data found
data: <%= inspect(@async.data.result) %>
error: <%= inspect(err) %>
@@ -384,4 +385,31 @@ defmodule Phoenix.LiveViewTest.AsyncLive do 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 handle_info(:boom, _socket), do: exit(:boom) + + def handle_info(:cancel, socket), do: {:noreply, cancel_async(socket, :data)} + + def handle_info(:renew_canceled, socket) do + {:noreply, assign_async(socket, :data, fn -> + Process.sleep(100) + {:ok, %{data: 123}} + end)} + end end From 2152fa3211acfc63d6b60657a3113c2690f15a74 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Sun, 6 Aug 2023 14:22:50 -0400 Subject: [PATCH 06/63] Enum protocol --- lib/phoenix_live_view/async_assign.ex | 43 +++++++++++++++++++ .../integrations/assign_async_test.exs | 8 ++++ test/support/live_views/general.ex | 23 ++++++++-- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_live_view/async_assign.ex b/lib/phoenix_live_view/async_assign.ex index 3b20d9d3b8..86046e386e 100644 --- a/lib/phoenix_live_view/async_assign.ex +++ b/lib/phoenix_live_view/async_assign.ex @@ -182,4 +182,47 @@ defmodule Phoenix.LiveView.AsyncAssign do socket end end + + defimpl Enumerable, for: Phoenix.LiveView.AsyncAssign do + alias Phoenix.LiveView.AsyncAssign + + def count(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}), + do: Enum.count(result) + + def count(%AsyncAssign{}), do: 0 + + def member?(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}, item) do + Enum.member?(result, item) + end + + def member?(%AsyncAssign{}, _item) do + raise RuntimeError, "cannot lookup member? while loading" + end + + def reduce( + %AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}, + acc, + fun + ) do + do_reduce(result, acc, fun) + end + + def reduce(%AsyncAssign{}, acc, _fun), do: acc + + defp do_reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} + defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)} + defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc} + + defp do_reduce([item | tail], {:cont, acc}, fun) do + do_reduce(tail, fun.(item, acc), fun) + end + + def slice(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}) do + fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end + end + + def slice(%AsyncAssign{}) do + fn _start, _length, _step -> [] end + end + end end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index d8073abe05..d88ac9e6b3 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -76,4 +76,12 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert render_async(lv, 200) =~ "data: 123" end end + + test "enum", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=enum") + + html = render_async(lv, 200) + assert html =~ "data: [1, 2, 3]" + assert html =~ "
1
2
3
" + end end diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 69ed0dc589..3845172e62 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -355,6 +355,12 @@ defmodule Phoenix.LiveViewTest.AsyncLive do end end + on_mount {__MODULE__, :defaults} + + def on_mount(:defaults, _params, _session, socket) do + {:cont, assign(socket, enum: false)} + end + def render(assigns) do ~H""" <.live_component module={LC} id="lc" /> @@ -363,6 +369,9 @@ defmodule Phoenix.LiveViewTest.AsyncLive do
no data found
data: <%= inspect(@async.data.result) %>
error: <%= inspect(err) %>
+ <%= if @enum do %> +
<%= i %>
+ <% end %> """ end @@ -402,14 +411,20 @@ defmodule Phoenix.LiveViewTest.AsyncLive do 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, :data)} def handle_info(:renew_canceled, socket) do - {:noreply, assign_async(socket, :data, fn -> - Process.sleep(100) - {:ok, %{data: 123}} - end)} + {:noreply, + assign_async(socket, :data, fn -> + Process.sleep(100) + {:ok, %{data: 123}} + end)} end end From f91f5013b5b1cb644cf6c5ebd8b0978c60e07c1d Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 7 Aug 2023 09:45:08 -0400 Subject: [PATCH 07/63] LiveComponent tests --- .../integrations/assign_async_test.exs | 93 ++++++++++++++- test/support/live_views/general.ex | 106 +++++++++++++++--- 2 files changed, 177 insertions(+), 22 deletions(-) diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index d88ac9e6b3..3380111a65 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -11,7 +11,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, conn: Plug.Test.init_test_session(Phoenix.ConnTest.build_conn(), %{})} end - describe "assign_async" do + describe "LiveView assign_async" do test "bad return", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=bad_return") @@ -75,13 +75,94 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert render(lv) =~ "data loading..." assert render_async(lv, 200) =~ "data: 123" end + + test "enum", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=enum") + + html = render_async(lv, 200) + assert html =~ "data: [1, 2, 3]" + assert html =~ "
1
2
3
" + end end - test "enum", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=enum") + describe "LiveComponent assign_async" do + test "bad return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=lc_bad_return") + + assert render_async(lv) =~ + "error: {:error, %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, "/async?test=lc_bad_ok") + + assert render_async(lv) =~ + "expected assign_async to return map of\\nassigns for all keys in [:lc_data]" + + assert render(lv) + end + + test "valid return", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=lc_ok") + assert render_async(lv) =~ "lc_data: 123" + end + + test "raise during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=lc_raise") + + assert render_async(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" + assert render(lv) + end + + test "exit during execution", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=lc_exit") + + assert render_async(lv) =~ "error: {:exit, :boom}" + assert render(lv) + end + + test "lv exit brings down asyncs", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/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} + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom} + end + + test "cancel_async", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/async?test=lc_cancel") + Process.unlink(lv.pid) + async_ref = Process.monitor(Process.whereis(:lc_cancel)) + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AsyncLive.LC, + id: "lc", + action: :cancel + ) + + assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - html = render_async(lv, 200) - assert html =~ "data: [1, 2, 3]" - assert html =~ "
1
2
3
" + assert render(lv) =~ "lc_data canceled" + + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AsyncLive.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, "/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/support/live_views/general.ex b/test/support/live_views/general.ex index 3845172e62..af38052fa5 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -341,40 +341,34 @@ 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 - on_mount {__MODULE__, :defaults} def on_mount(:defaults, _params, _session, socket) do - {:cont, assign(socket, enum: false)} + {:cont, assign(socket, enum: false, lc: false)} end def render(assigns) do ~H""" - <.live_component module={LC} id="lc" /> + <.live_component :if={@lc} module={Phoenix.LiveViewTest.AsyncLive.LC} test={@lc} id="lc" />
data loading...
data canceled
no data found
data: <%= inspect(@async.data.result) %>
error: <%= inspect(err) %>
+ <%= 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 @@ -428,3 +422,83 @@ defmodule Phoenix.LiveViewTest.AsyncLive do end)} end end + +defmodule Phoenix.LiveViewTest.AsyncLive.LC do + use Phoenix.LiveComponent + import Phoenix.LiveView.AsyncAssign + + def render(assigns) do + ~H""" +
+
lc_data loading...
+
lc_data canceled
+
no lc_data found
+
lc_data: <%= inspect(@async.lc_data.result) %>
+
error: <%= inspect(err) %>
+ + <%= if @enum do %> +
<%= i %>
+ <% end %> +
+ """ + 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, :lc_data)} + + def update(%{action: :renew_canceled}, socket) do + {:ok, + assign_async(socket, :lc_data, fn -> + Process.sleep(100) + {:ok, %{lc_data: 123}} + end)} + end +end From 44c2ad3c8283c87ef34ed34b2ef97b38acc94b81 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 7 Aug 2023 10:35:00 -0400 Subject: [PATCH 08/63] Docs --- .formatter.exs | 13 +++ lib/phoenix_live_view/async_assign.ex | 146 ++++++++++++++++++++------ 2 files changed, 129 insertions(+), 30 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 47616780b5..212763e89e 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,4 +1,17 @@ +locals_without_parens = [ + attr: 2, + attr: 3, + live: 2, + live: 3, + live: 4, + on_mount: 1, + slot: 1, + slot: 2, + slot: 3 +] + [ + locals_without_parens: locals_without_parens, import_deps: [:phoenix], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/lib/phoenix_live_view/async_assign.ex b/lib/phoenix_live_view/async_assign.ex index 86046e386e..f914897142 100644 --- a/lib/phoenix_live_view/async_assign.ex +++ b/lib/phoenix_live_view/async_assign.ex @@ -1,38 +1,69 @@ defmodule Phoenix.LiveView.AsyncAssign do @moduledoc ~S''' - Adds async_assign functionality to LiveViews. + Adds async_assign functionality to LiveViews and LiveComponents. - ## Examples - - defmodule MyLive do - - def render(assigns) do + 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. 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 + error and translate it to a meaningful update in the UI rather than crashing + the user experience. - ~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> + ## Examples -
...
- """ - end + 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, [:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + 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 + Here we are assigning `:org` and `[:profile, :rank]` asynchronously. If no keys are + given (as in the case of `:org`), the keys will default to `[:org]`. 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 under the `@async` assign + on the socket with the name given to `assign_async/3`. It carries the `:loading?`, + `:error`, and `:result` keys. 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...
+
there was an error loading the organization
+
You don't have an org yet
+
<%= org.name%> loaded!
+ ``` + + The `async_result` function component can also be used to declaratively + render the different states using slots: + + ```heex + <.async_result :let={org} assign={@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> + ``` + + Additionally, for async assigns which result in a list of items, you + can consume the `@async.` directly, and it will only enumerate + the results once the results are loaded. For example: + + ```heex +
<%= org.name %>
+ ``` ''' + use Phoenix.Component defstruct name: nil, ref: nil, @@ -46,7 +77,56 @@ defmodule Phoenix.LiveView.AsyncAssign do alias Phoenix.LiveView.AsyncAssign @doc """ - TODO + Renders an async assign with slots for the different loading states. + + ## Examples + + ```heex + <.async_result :let={org} assign={@async.org}> + <:loading>Loading organization... + <:empty>You don't have an organization yet + <:error :let={_reason}>there was an error loading the organization + <:canceled :let={_reason}>loading cancled + <%= org.name %> + <.async_result> + ``` + """ + attr :assign, :any, required: true + slot :loading + slot :canceled + + slot :empty, + doc: + "rendered when the result is loaded and is either nil or an empty enumerable. Receives the result as a :let." + + slot :error, + doc: + "rendered when an error is caught or the function return `{:error, reason}`. Receives the error as a :let." + + def async_result(assigns) do + case assigns.assign do + %AsyncAssign{result: result, loading?: false, error: nil, canceled?: false} -> + if assigns.empty != [] && (is_nil(result) or Enum.empty?(result)) do + ~H|<%= render_slot(@empty, @assign.result) %>| + else + ~H|<%= render_slot(@inner_block, @assign.result) %>| + end + + %AsyncAssign{loading?: true} -> + ~H|<%= render_slot(@loading) %>| + + %AsyncAssign{loading?: false, error: error} when not is_nil(error) -> + ~H|<%= render_slot(@error, @assign.error) %>| + + %AsyncAssign{loading?: false, canceled?: true} -> + ~H|<%= render_slot(@canceled) %>| + end + end + + @doc """ + Assigns keys ansynchronously. + + See the module docs for more and exmaple usage. """ def assign_async(%Phoenix.LiveView.Socket{} = socket, name, func) do assign_async(socket, name, [name], func) @@ -77,7 +157,13 @@ defmodule Phoenix.LiveView.AsyncAssign do end @doc """ - TODO + Cancels an async assign. + + ## Examples + + def handle_event("cancel_preview", _, socket) do + {:noreply, cancel_async(socket, :preview)} + end """ def cancel_async(%Phoenix.LiveView.Socket{} = socket, name) do case get(socket, name) do From 5226415a17278f257bc7c641f7f20420c4b4a411 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 7 Aug 2023 11:04:10 -0400 Subject: [PATCH 09/63] FC --- lib/phoenix_live_view/async_assign.ex | 4 ++-- test/support/live_views/general.ex | 14 ++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/phoenix_live_view/async_assign.ex b/lib/phoenix_live_view/async_assign.ex index f914897142..c5d67c2987 100644 --- a/lib/phoenix_live_view/async_assign.ex +++ b/lib/phoenix_live_view/async_assign.ex @@ -97,7 +97,7 @@ defmodule Phoenix.LiveView.AsyncAssign do slot :empty, doc: - "rendered when the result is loaded and is either nil or an empty enumerable. Receives the result as a :let." + "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." slot :error, doc: @@ -106,7 +106,7 @@ defmodule Phoenix.LiveView.AsyncAssign do def async_result(assigns) do case assigns.assign do %AsyncAssign{result: result, loading?: false, error: nil, canceled?: false} -> - if assigns.empty != [] && (is_nil(result) or Enum.empty?(result)) do + if assigns.empty != [] && result in [nil, []] do ~H|<%= render_slot(@empty, @assign.result) %>| else ~H|<%= render_slot(@inner_block, @assign.result) %>| diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index af38052fa5..ab343edc4b 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -430,15 +430,17 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do def render(assigns) do ~H"""
-
lc_data loading...
-
lc_data canceled
-
no lc_data found
-
lc_data: <%= inspect(@async.lc_data.result) %>
-
error: <%= inspect(err) %>
- <%= if @enum do %>
<%= i %>
<% end %> + <.async_result :let={data} assign={@async.lc_data}> + <:loading>lc_data loading... + <:canceled>lc_data canceled + <:empty :let={_res}>no lc_data found + <:error :let={err}>error: <%= inspect(err) %> + + lc_data: <%= inspect(data) %> +
""" end From ab39c680e2485b3948461fa972e36f849feb8f47 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 10 Aug 2023 23:53:04 -0400 Subject: [PATCH 10/63] AsyncResult WIP --- lib/phoenix_live_view.ex | 35 ++ lib/phoenix_live_view/async_assign.ex | 314 ------------------ lib/phoenix_live_view/async_result.ex | 449 ++++++++++++++++++++++++++ lib/phoenix_live_view/channel.ex | 28 +- test/phoenix_live_view_test.exs | 13 + test/support/live_views/general.ex | 37 ++- 6 files changed, 532 insertions(+), 344 deletions(-) delete mode 100644 lib/phoenix_live_view/async_assign.ex create mode 100644 lib/phoenix_live_view/async_result.ex diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 4ef2fe5223..c96ef37b2f 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -666,6 +666,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 retreived: + + 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. @@ -690,6 +724,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 """ diff --git a/lib/phoenix_live_view/async_assign.ex b/lib/phoenix_live_view/async_assign.ex deleted file mode 100644 index c5d67c2987..0000000000 --- a/lib/phoenix_live_view/async_assign.ex +++ /dev/null @@ -1,314 +0,0 @@ -defmodule Phoenix.LiveView.AsyncAssign do - @moduledoc ~S''' - Adds async_assign functionality to LiveViews and LiveComponents. - - 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. 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 - error and translate it to a meaningful update in the UI rather than crashing - the user experience. - - ## Examples - - 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, [:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} - end - - Here we are assigning `:org` and `[:profile, :rank]` asynchronously. If no keys are - given (as in the case of `:org`), the keys will default to `[:org]`. 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 under the `@async` assign - on the socket with the name given to `assign_async/3`. It carries the `:loading?`, - `:error`, and `:result` keys. 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...
-
there was an error loading the organization
-
You don't have an org yet
-
<%= org.name%> loaded!
- ``` - - The `async_result` function component can also be used to declaratively - render the different states using slots: - - ```heex - <.async_result :let={org} assign={@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> - ``` - - Additionally, for async assigns which result in a list of items, you - can consume the `@async.` directly, and it will only enumerate - the results once the results are loaded. For example: - - ```heex -
<%= org.name %>
- ``` - ''' - use Phoenix.Component - - defstruct name: nil, - ref: nil, - pid: nil, - keys: [], - loading?: false, - error: nil, - result: nil, - canceled?: false - - alias Phoenix.LiveView.AsyncAssign - - @doc """ - Renders an async assign with slots for the different loading states. - - ## Examples - - ```heex - <.async_result :let={org} assign={@async.org}> - <:loading>Loading organization... - <:empty>You don't have an organization yet - <:error :let={_reason}>there was an error loading the organization - <:canceled :let={_reason}>loading cancled - <%= org.name %> - <.async_result> - ``` - """ - attr :assign, :any, required: true - slot :loading - slot :canceled - - slot :empty, - doc: - "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." - - slot :error, - doc: - "rendered when an error is caught or the function return `{:error, reason}`. Receives the error as a :let." - - def async_result(assigns) do - case assigns.assign do - %AsyncAssign{result: result, loading?: false, error: nil, canceled?: false} -> - if assigns.empty != [] && result in [nil, []] do - ~H|<%= render_slot(@empty, @assign.result) %>| - else - ~H|<%= render_slot(@inner_block, @assign.result) %>| - end - - %AsyncAssign{loading?: true} -> - ~H|<%= render_slot(@loading) %>| - - %AsyncAssign{loading?: false, error: error} when not is_nil(error) -> - ~H|<%= render_slot(@error, @assign.error) %>| - - %AsyncAssign{loading?: false, canceled?: true} -> - ~H|<%= render_slot(@canceled) %>| - end - end - - @doc """ - Assigns keys ansynchronously. - - See the module docs for more and exmaple usage. - """ - 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 """ - Cancels an async assign. - - ## Examples - - def handle_event("cancel_preview", _, socket) do - {:noreply, cancel_async(socket, :preview)} - end - """ - 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 - - defimpl Enumerable, for: Phoenix.LiveView.AsyncAssign do - alias Phoenix.LiveView.AsyncAssign - - def count(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}), - do: Enum.count(result) - - def count(%AsyncAssign{}), do: 0 - - def member?(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}, item) do - Enum.member?(result, item) - end - - def member?(%AsyncAssign{}, _item) do - raise RuntimeError, "cannot lookup member? while loading" - end - - def reduce( - %AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}, - acc, - fun - ) do - do_reduce(result, acc, fun) - end - - def reduce(%AsyncAssign{}, acc, _fun), do: acc - - defp do_reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} - defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)} - defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc} - - defp do_reduce([item | tail], {:cont, acc}, fun) do - do_reduce(tail, fun.(item, acc), fun) - end - - def slice(%AsyncAssign{result: result, loading?: false, error: nil, canceled?: false}) do - fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end - end - - def slice(%AsyncAssign{}) do - fn _start, _length, _step -> [] end - end - 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..bed8c27cf2 --- /dev/null +++ b/lib/phoenix_live_view/async_result.ex @@ -0,0 +1,449 @@ +defmodule Phoenix.LiveView.AsyncResult do + @moduledoc ~S''' + Adds async_assign functionality to LiveViews and LiveComponents. + + 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. 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 + error and translate it to a meaningful update in the UI rather than crashing + the user experience. + + ## Examples + + 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, [:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + end + + Here we are assigning `:org` and `[:profile, :rank]` asynchronously. If no keys are + given (as in the case of `:org`), the keys will default to `[:org]`. 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 under the `@async` assign + on the socket with the name given to `assign_async/3`. It carries the `:loading?`, + `:error`, and `:result` keys. 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...
+
there was an error loading the organization
+
You don't have an org yet
+
<%= org.name%> loaded!
+ ``` + + The `async_result` function component can also be used to declaratively + render the different states using slots: + + ```heex + <.async_result :let={org} assign={@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> + ``` + + Additionally, for async assigns which result in a list of items, you + can consume the `@async.` directly, and it will only enumerate + the results once the results are loaded. For example: + + ```heex +
<%= org.name %>
+ ``` + ''' + use Phoenix.Component + + defstruct name: nil, + keys: [], + ok?: false, + state: :loading, + result: nil + + alias Phoenix.LiveView.{AsyncResult, Socket} + + @doc """ + TODO + """ + def new(name, keys) do + loading(%AsyncResult{name: name, keys: keys, result: nil, ok?: false}) + end + + @doc """ + TODO + """ + def loading(%AsyncResult{} = result) do + %AsyncResult{result | state: :loading} + end + + @doc """ + TODO + """ + def canceled(%AsyncResult{} = result) do + %AsyncResult{result | state: :canceled} + end + + @doc """ + TODO + """ + def error(%AsyncResult{} = result, reason) do + %AsyncResult{result | state: {:error, reason}} + end + + @doc """ + TODO + """ + def exit(%AsyncResult{} = result, reason) do + %AsyncResult{result | state: {:exit, reason}} + end + + @doc """ + TODO + """ + def throw(%AsyncResult{} = result, value) do + %AsyncResult{result | state: {:throw, value}} + end + + @doc """ + TODO + """ + def ok(%AsyncResult{} = result, value) do + %AsyncResult{result | state: :ok, ok?: true, result: value} + end + + @doc """ + Renders an async assign with slots for the different loading states. + + ## Examples + + ```heex + + <:loading>Loading organization... + <:empty>You don't have an organization yet + <:error :let={_reason}>there was an error loading the organization + <:canceled :let={_reason}>loading cancled + <%= org.name %> + + ``` + """ + attr :assign, :any, required: true + slot :loading + slot :canceled + + slot :empty, + doc: + "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." + + slot :error, + doc: + "rendered when an error is caught or the function return `{:error, reason}`. Receives the error as a :let." + + def with_state(assigns) do + case assigns.assign do + %AsyncResult{state: :ok, result: result} -> + if assigns.empty != [] && result in [nil, []] do + ~H|<%= render_slot(@empty, @assign.result) %>| + else + ~H|<%= render_slot(@inner_block, @assign.result) %>| + end + + %AsyncResult{state: :loading} -> + ~H|<%= render_slot(@loading) %>| + + %AsyncResult{state: :canceled} -> + ~H|<%= render_slot(@canceled) %>| + + %AsyncResult{state: {kind, _value}} when kind in [:error, :exit, :throw] -> + ~H|<%= render_slot(@error, @assign.state) %>| + 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. + """ + 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) + + keys + |> Enum.reduce(socket, fn key, acc -> + Phoenix.Component.assign(acc, key, AsyncResult.new(key, keys)) + end) + |> run_async_task(keys, func, fn new_socket, _component_mod, result -> + assign_result(new_socket, keys, result) + end) + end + + defp assign_result(socket, keys, result) do + case result do + {:ok, %{} = values} -> + if Map.keys(values) -- keys == [] do + Enum.reduce(values, socket, fn {key, val}, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) + end) + else + raise ArgumentError, """ + expected assign_async to return map of assigns for all keys + in #{inspect(keys)}, but got: #{inspect(values)} + """ + end + + {:error, reason} -> + Enum.reduce(keys, socket, fn key, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) + end) + + {:exit, reason} -> + Enum.reduce(keys, socket, fn key, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, reason)) + end) + + {:throw, value} -> + Enum.reduce(keys, socket, fn key, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.throw(current_async, value)) + end) + + other -> + raise ArgumentError, """ + expected assign_async to return {:ok, map} of + assigns for #{inspect(keys)} or {:error, reason}, got: #{inspect(other)} + """ + end + end + + defp get_current_async!(socket, key) do + # handle case where assign is temporary and needs to be rebuilt + case socket.assigns do + %{^key => %AsynResult{} = current_async} -> current_async + %{^key => _other} -> AsyncResult.new(key, keys) + %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" + end + end + + @doc """ + Starts an ansynchronous task. + + The task is linked to the caller and errors are wrapped. + The result of the task is sent to the `handle_async/3` callback + of the caller LiveView or LiveComponent. + + ## Examples + + + """ + def start_async(%Socket{} = socket, name, func) + when is_atom(name) and is_function(func, 0) do + run_async_task(socket, [name], func, fn new_socket, component_mod, result -> + callback_mod = component_mod || new_socket.view + + case result do + {tag, value} when tag in [:ok, :error, :exit, :throw] -> + :ok + + other -> + raise ArgumentError, """ + expected start_async for #{inspect(name)} in #{inspect(callback_mod)} + to return {:ok, result} | {:error, reason}, got: + + #{inspect(other)} + """ + end + + case callback_mod.handle_async(name, result, new_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) + end + + defp run_async_task(%Socket{} = socket, keys, func, result_func) + when is_list(keys) and is_function(result_func, 3) do + if Phoenix.LiveView.connected?(socket) do + socket = cancel_existing(socket, keys) + lv_pid = self() + cid = cid(socket) + ref = make_ref() + {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, ref, keys, func, result_func) end) + update_private_async(socket, keys, {ref, pid}) + else + socket + end + end + + defp do_async(lv_pid, cid, ref, keys, func, result_func) do + try do + result = func.() + + Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> + handle_current_async(socket, keys, ref, component_mod, result, result_func) + end) + catch + kind, reason -> + Process.unlink(lv_pid) + + Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> + handle_current_async(socket, keys, ref, component_mod, {kind, reason}, result_func) + end) + + :erlang.raise(kind, reason, __STACKTRACE__) + end + end + + # handle race of async being canceled and then reassigned + defp handle_current_async(socket, keys, ref, component_mod, result, result_func) + when is_function(result_func, 3) do + get_private_async(socket, keys) do + {^ref, pid} -> + new_socket = delete_private_async(socket, keys) + result_func.(new_socket, component_mod, result) + + {_ref, _pid} -> + socket + + nil -> + socket + end + end + + @doc """ + Cancels an async assign. + + ## Examples + + TODO fix docs + def handle_event("cancel_preview", _, socket) do + {:noreply, cancel_async(socket, :preview)} + end + """ + def cancel_async(%Socket{} = socket, %AsyncResult{} = result) do + result.keys + |> Enum.reduce(socket, fn key, acc -> + Phoenix.Component.assign(acc, key, AsyncResult.canceled(result)) + end) + |> cancel_async(result.keys) + end + + def cancel_async(%Socket{} = socket, key) when is_atom(key) do + cancel_async(socket, [key]) + end + + def cancel_async(%Socket{} = socket, keys) when is_list(keys) do + case get_private_async(socket, keys) do + {ref, pid} when is_pid(pid) -> + Process.unlink(pid) + Process.exit(pid, :kill) + delete_private_async(socket, keys) + + nil -> + raise ArgumentError, "uknown async assign #{inspect(keys)}" + end + end + + defp update_private_async(socket, keys, {ref, pid}) do + socket + |> ensure_private_async() + |> Phoenix.Component.update(:async, fn async_map -> + Map.put(async_map, keys, {ref, pid}) + end) + end + + defp delete_private_async(socket, keys) do + socket + |> ensure_private_async() + |> Phoenix.Component.update(:async, fn async_map -> Map.delete(async_map, keys) end) + end + + defp ensure_private_async(socket) do + case socket.private do + %{phoenix_async: _} -> socket + %{} -> Phoenix.LiveView.put_private(socket, :phoenix_async, %{}) + end + end + + defp get_private_async(%Socket{} = socket, keys) do + socket.private[:phoenix_async][keys] + end + + defp cancel_existing(socket, keys) when is_list(keys) do + if get_private_async(acc, keys) do + cancel_async(acc, keys) + else + acc + end + end + + defp cid(%Socket{} = socket) do + if myself = socket.assigns[:myself], do: myself.cid + end + + defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do + alias Phoenix.LiveView.AsyncResult + + def count(%AsyncResult{result: result, state: :ok}), + do: Enum.count(result) + + def count(%AsyncResult{}), do: 0 + + def member?(%AsyncResult{result: result, state: :ok}, item) do + Enum.member?(result, item) + end + + def member?(%AsyncResult{}, _item) do + raise RuntimeError, "cannot lookup member? without an ok result" + end + + def reduce( + %AsyncResult{result: result, state: :ok}, + acc, + fun + ) do + do_reduce(result, acc, fun) + end + + def reduce(%AsyncResult{}, acc, _fun), do: acc + + defp do_reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} + defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)} + defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc} + + defp do_reduce([item | tail], {:cont, acc}, fun) do + do_reduce(tail, fun.(item, acc), fun) + end + + def slice(%AsyncResult{result: result, state: :ok}) do + fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end + 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 5728659a44..ad55707c02 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -30,7 +30,7 @@ defmodule Phoenix.LiveView.Channel do ) end - def write_socket(lv_pid, cid, func) when is_function(func, 1) do + def write_socket(lv_pid, cid, func) when is_function(func, 2) do GenServer.call(lv_pid, {@prefix, :write_socket, cid, func}) end @@ -292,23 +292,20 @@ defmodule Phoenix.LiveView.Channel do def handle_call({@prefix, :async_pids}, _from, state) do %{socket: socket} = state - lv_pids = get_async_pids(socket.assigns) + lv_pids = get_async_pids(socket.private) component_pids = state - |> component_assigns() - |> Enum.flat_map(fn - {_cid, %{async: %{}} = assigns} -> get_async_pids(assigns) - {_cid, %{}} -> [] - end) + |> component_privates() + |> get_async_pids() {: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) + write_socket(state, cid, nil, fn socket, maybe_component -> + %Phoenix.LiveView.Socket{} = new_socket = func.(socket, maybe_component) {new_socket, {:ok, nil, state}} end) @@ -1425,15 +1422,18 @@ defmodule Phoenix.LiveView.Channel do defp maybe_subscribe_to_live_reload(response), do: response - defp component_assigns(state) do + defp component_privates(state) do %{components: {components, _ids, _}} = state - Enum.into(components, %{}, fn {cid, {_mod, _id, assigns, _private, _prints}} -> - {cid, assigns} + Enum.into(components, %{}, fn {cid, {_mod, _id, _assigns, private, _prints}} -> + {cid, private} end) end - defp get_async_pids(assigns) do - Enum.map(assigns[:async] || %{}, fn {_, %Phoenix.LiveView.AsyncAssign{pid: pid}} -> pid end) + defp get_async_pids(private) do + case private do + %{phoenix_async: ref_pids} -> Map.values(ref_pids) + %{} -> [] + end end end diff --git a/test/phoenix_live_view_test.exs b/test/phoenix_live_view_test.exs index 38a772eea9..8e8a34600f 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] == "word" + 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 ab343edc4b..90a7b6b872 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -339,9 +339,9 @@ end defmodule Phoenix.LiveViewTest.AsyncLive do use Phoenix.LiveView - import Phoenix.LiveView.AsyncAssign + import Phoenix.LiveView.AsyncResult - on_mount {__MODULE__, :defaults} + on_mount({__MODULE__, :defaults}) def on_mount(:defaults, _params, _session, socket) do {:cont, assign(socket, enum: false, lc: false)} @@ -350,14 +350,15 @@ defmodule Phoenix.LiveViewTest.AsyncLive do def render(assigns) do ~H""" <.live_component :if={@lc} module={Phoenix.LiveViewTest.AsyncLive.LC} test={@lc} id="lc" /> -
data loading...
-
data canceled
-
no data found
-
data: <%= inspect(@async.data.result) %>
-
error: <%= inspect(err) %>
- +
data loading...
+
data canceled
+
no data found
+
data: <%= inspect(@data.result) %>
+ <%= with {kind, reason} when kind in [:error, :exit, :throw] <- @data.state do %> +
<%= kind %>: <%= inspect(reason) %>
+ <% end %> <%= if @enum do %> -
<%= i %>
+
<%= i %>
<% end %> """ end @@ -412,7 +413,9 @@ defmodule Phoenix.LiveViewTest.AsyncLive do def handle_info(:boom, _socket), do: exit(:boom) - def handle_info(:cancel, socket), do: {:noreply, cancel_async(socket, :data)} + def handle_info(:cancel, socket) do + {:noreply, cancel_async(socket, socket.assigns.data)} + end def handle_info(:renew_canceled, socket) do {:noreply, @@ -425,22 +428,22 @@ end defmodule Phoenix.LiveViewTest.AsyncLive.LC do use Phoenix.LiveComponent - import Phoenix.LiveView.AsyncAssign + import Phoenix.LiveView.AsyncResult def render(assigns) do ~H"""
<%= if @enum do %> -
<%= i %>
+
<%= i %>
<% end %> - <.async_result :let={data} assign={@async.lc_data}> + <:loading>lc_data loading... <:canceled>lc_data canceled <:empty :let={_res}>no lc_data found - <:error :let={err}>error: <%= inspect(err) %> + <:error :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> lc_data: <%= inspect(data) %> - +
""" end @@ -494,7 +497,9 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do def update(%{action: :boom}, _socket), do: exit(:boom) - def update(%{action: :cancel}, socket), do: {:ok, cancel_async(socket, :lc_data)} + def update(%{action: :cancel}, socket) do + {:ok, cancel_async(socket, socket.assigns.lc_data)} + end def update(%{action: :renew_canceled}, socket) do {:ok, From eda390718bdda227dfc569c0c61848639223901c Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 15:51:28 -0400 Subject: [PATCH 11/63] Checkpoint --- lib/phoenix_live_view/async_result.ex | 146 ++++++++---------- lib/phoenix_live_view/channel.ex | 4 +- .../integrations/assign_async_test.exs | 16 +- test/phoenix_live_view_test.exs | 2 +- test/support/live_views/general.ex | 3 +- 5 files changed, 77 insertions(+), 94 deletions(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index bed8c27cf2..0c9e0d2d64 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -50,7 +50,7 @@ defmodule Phoenix.LiveView.AsyncResult do <.async_result :let={org} assign={@async.org}> <:loading>Loading organization... <:empty>You don't have an organization yet - <:error>there was an error loading the organization + <:failed>there was an error loading the organization <%= org.name %> <.async_result> ``` @@ -108,13 +108,6 @@ defmodule Phoenix.LiveView.AsyncResult do %AsyncResult{result | state: {:exit, reason}} end - @doc """ - TODO - """ - def throw(%AsyncResult{} = result, value) do - %AsyncResult{result | state: {:throw, value}} - end - @doc """ TODO """ @@ -164,7 +157,7 @@ defmodule Phoenix.LiveView.AsyncResult do %AsyncResult{state: :canceled} -> ~H|<%= render_slot(@canceled) %>| - %AsyncResult{state: {kind, _value}} when kind in [:error, :exit, :throw] -> + %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> ~H|<%= render_slot(@error, @assign.state) %>| end end @@ -182,61 +175,68 @@ defmodule Phoenix.LiveView.AsyncResult do is_function(func, 0) do keys = List.wrap(key_or_keys) + # verifies result inside task + wrapped_func = fn -> + case func.() do + {:ok, %{} = assigns} -> + if Map.keys(assigns) -- keys == [] do + {:ok, assigns} + else + raise ArgumentError, """ + expected assign_async to return map of assigns for all keys + in #{inspect(keys)}, but got: #{inspect(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 + keys |> Enum.reduce(socket, fn key, acc -> Phoenix.Component.assign(acc, key, AsyncResult.new(key, keys)) end) - |> run_async_task(keys, func, fn new_socket, _component_mod, result -> + |> run_async_task(keys, wrapped_func, fn new_socket, _component_mod, result -> assign_result(new_socket, keys, result) end) end defp assign_result(socket, keys, result) do case result do - {:ok, %{} = values} -> - if Map.keys(values) -- keys == [] do - Enum.reduce(values, socket, fn {key, val}, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) - end) - else - raise ArgumentError, """ - expected assign_async to return map of assigns for all keys - in #{inspect(keys)}, but got: #{inspect(values)} - """ - end - - {:error, reason} -> - Enum.reduce(keys, socket, fn key, acc -> + {:ok, {:ok, %{} = assigns}} -> + Enum.reduce(assigns, socket, fn {key, val}, acc -> current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) + Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) end) - {:exit, reason} -> + {:ok, {:error, reason}} -> Enum.reduce(keys, socket, fn key, acc -> current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, reason)) + Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) end) - {:throw, value} -> + {:catch, kind, reason, stack} -> + normalized_exit = to_exit(kind, reason, stack) + Enum.reduce(keys, socket, fn key, acc -> current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.throw(current_async, value)) + Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, normalized_exit)) end) - - other -> - raise ArgumentError, """ - expected assign_async to return {:ok, map} of - assigns for #{inspect(keys)} or {:error, reason}, got: #{inspect(other)} - """ end end defp get_current_async!(socket, key) do # handle case where assign is temporary and needs to be rebuilt case socket.assigns do - %{^key => %AsynResult{} = current_async} -> current_async - %{^key => _other} -> AsyncResult.new(key, keys) + %{^key => %AsyncResult{} = current_async} -> current_async + %{^key => _other} -> AsyncResult.new(key, key) %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" end end @@ -257,20 +257,13 @@ defmodule Phoenix.LiveView.AsyncResult do run_async_task(socket, [name], func, fn new_socket, component_mod, result -> callback_mod = component_mod || new_socket.view - case result do - {tag, value} when tag in [:ok, :error, :exit, :throw] -> - :ok - - other -> - raise ArgumentError, """ - expected start_async for #{inspect(name)} in #{inspect(callback_mod)} - to return {:ok, result} | {:error, reason}, got: - - #{inspect(other)} - """ - end + normalized_result = + case result do + {:ok, result} -> {:ok, result} + {:catch, kind, reason, stack} -> {:exit, to_exit(kind, reason, stack)} + end - case callback_mod.handle_async(name, result, new_socket) do + case callback_mod.handle_async(name, normalized_result, new_socket) do {:noreply, %Socket{} = new_socket} -> new_socket @@ -292,7 +285,7 @@ defmodule Phoenix.LiveView.AsyncResult do cid = cid(socket) ref = make_ref() {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, ref, keys, func, result_func) end) - update_private_async(socket, keys, {ref, pid}) + update_private_async(socket, &Map.put(&1, keys, {ref, pid})) else socket end @@ -303,26 +296,31 @@ defmodule Phoenix.LiveView.AsyncResult do result = func.() Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> - handle_current_async(socket, keys, ref, component_mod, result, result_func) + handle_current_async(socket, keys, ref, component_mod, {:ok, result}, result_func) end) catch kind, reason -> Process.unlink(lv_pid) + caught_result = {:catch, kind, reason, __STACKTRACE__} Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> - handle_current_async(socket, keys, ref, component_mod, {kind, reason}, result_func) + handle_current_async(socket, keys, ref, component_mod, caught_result, result_func) end) :erlang.raise(kind, reason, __STACKTRACE__) end end + defp to_exit(:throw, reason, stack), do: {{:nocatch, reason}, stack} + defp to_exit(:error, reason, stack), do: {reason, stack} + defp to_exit(:exit, reason, _stack), do: reason + # handle race of async being canceled and then reassigned defp handle_current_async(socket, keys, ref, component_mod, result, result_func) when is_function(result_func, 3) do - get_private_async(socket, keys) do - {^ref, pid} -> - new_socket = delete_private_async(socket, keys) + case get_private_async(socket, keys) do + {^ref, _pid} -> + new_socket = update_private_async(socket, &Map.delete(&1, keys)) result_func.(new_socket, component_mod, result) {_ref, _pid} -> @@ -357,46 +355,30 @@ defmodule Phoenix.LiveView.AsyncResult do def cancel_async(%Socket{} = socket, keys) when is_list(keys) do case get_private_async(socket, keys) do - {ref, pid} when is_pid(pid) -> + {_ref, pid} when is_pid(pid) -> Process.unlink(pid) Process.exit(pid, :kill) - delete_private_async(socket, keys) + update_private_async(socket, &Map.delete(&1, keys)) nil -> raise ArgumentError, "uknown async assign #{inspect(keys)}" end end - defp update_private_async(socket, keys, {ref, pid}) do - socket - |> ensure_private_async() - |> Phoenix.Component.update(:async, fn async_map -> - Map.put(async_map, keys, {ref, pid}) - end) - end - - defp delete_private_async(socket, keys) do - socket - |> ensure_private_async() - |> Phoenix.Component.update(:async, fn async_map -> Map.delete(async_map, keys) end) - end - - defp ensure_private_async(socket) do - case socket.private do - %{phoenix_async: _} -> socket - %{} -> Phoenix.LiveView.put_private(socket, :phoenix_async, %{}) - 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, keys) do socket.private[:phoenix_async][keys] end - defp cancel_existing(socket, keys) when is_list(keys) do - if get_private_async(acc, keys) do - cancel_async(acc, keys) + defp cancel_existing(%Socket{} = socket, keys) when is_list(keys) do + if get_private_async(socket, keys) do + cancel_async(socket, keys) else - acc + socket end end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index ad55707c02..83b2d9c24c 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -297,7 +297,7 @@ defmodule Phoenix.LiveView.Channel do component_pids = state |> component_privates() - |> get_async_pids() + |> Enum.flat_map(fn {_cid, private} -> get_async_pids(private) end) {:reply, {:ok, lv_pids ++ component_pids}, state} end @@ -1432,7 +1432,7 @@ defmodule Phoenix.LiveView.Channel do defp get_async_pids(private) do case private do - %{phoenix_async: ref_pids} -> Map.values(ref_pids) + %{phoenix_async: ref_pids} -> Enum.flat_map(ref_pids, fn {_key, {_ref, pid}} -> [pid] end) %{} -> [] end end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 3380111a65..34a6e5daf3 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -16,7 +16,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, lv, _html} = live(conn, "/async?test=bad_return") assert render_async(lv) =~ - "error: {:error, %ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:data] or {:error, reason}, got: 123\\n"}}" + "exit: {%ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:data] or {:error, reason}, got: 123\\n"}" assert render(lv) end @@ -25,7 +25,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, lv, _html} = live(conn, "/async?test=bad_ok") assert render_async(lv) =~ - "expected assign_async to return map of\\nassigns for all keys in [:data]" + "expected assign_async to return map of assigns for all keys\\nin [:data]" assert render(lv) end @@ -38,14 +38,14 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "raise during execution", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=raise") - assert render_async(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" + assert render_async(lv) =~ "exit: {%RuntimeError{message: "boom"}" assert render(lv) end test "exit during execution", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=exit") - assert render_async(lv) =~ "error: {:exit, :boom}" + assert render_async(lv) =~ "exit: :boom" assert render(lv) end @@ -90,7 +90,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, lv, _html} = live(conn, "/async?test=lc_bad_return") assert render_async(lv) =~ - "error: {:error, %ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:lc_data] or {:error, reason}, got: 123\\n"}}" + "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 @@ -99,7 +99,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do {:ok, lv, _html} = live(conn, "/async?test=lc_bad_ok") assert render_async(lv) =~ - "expected assign_async to return map of\\nassigns for all keys in [:lc_data]" + "expected assign_async to return map of assigns for all keys\\nin [:lc_data]" assert render(lv) end @@ -112,14 +112,14 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "raise during execution", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=lc_raise") - assert render_async(lv) =~ "error: {:error, %RuntimeError{message: "boom"}}" + assert render_async(lv) =~ "exit: {%RuntimeError{message: "boom"}" assert render(lv) end test "exit during execution", %{conn: conn} do {:ok, lv, _html} = live(conn, "/async?test=lc_exit") - assert render_async(lv) =~ "error: {:exit, :boom}" + assert render_async(lv) =~ "exit: :boom" assert render(lv) end diff --git a/test/phoenix_live_view_test.exs b/test/phoenix_live_view_test.exs index 8e8a34600f..e22ab776c5 100644 --- a/test/phoenix_live_view_test.exs +++ b/test/phoenix_live_view_test.exs @@ -292,7 +292,7 @@ defmodule Phoenix.LiveViewUnitTest do describe "put_private" do test "assigns private keys" do assert @socket.private[:hello] == nil - assert put_private(@socket, :hello, "world").private[:hello] == "word" + assert put_private(@socket, :hello, "world").private[:hello] == "world" end test "disallows reserved keys" do diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 90a7b6b872..b7e9345bbc 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -354,7 +354,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do
data canceled
no data found
data: <%= inspect(@data.result) %>
- <%= with {kind, reason} when kind in [:error, :exit, :throw] <- @data.state do %> + <%= with {kind, reason} when kind in [:error, :exit] <- @data.state do %>
<%= kind %>: <%= inspect(reason) %>
<% end %> <%= if @enum do %> @@ -428,6 +428,7 @@ end defmodule Phoenix.LiveViewTest.AsyncLive.LC do use Phoenix.LiveComponent + alias Phoenix.LiveView.AsyncResult import Phoenix.LiveView.AsyncResult def render(assigns) do From 1d2055774753eb9bd66ae8ea7ffb09a31c65bf48 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 16:36:46 -0400 Subject: [PATCH 12/63] Docs --- lib/phoenix_live_view/async_result.ex | 133 +++++++++++++----- .../integrations/assign_async_test.exs | 2 +- test/support/live_views/general.ex | 3 +- 3 files changed, 100 insertions(+), 38 deletions(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 0c9e0d2d64..29138dca45 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -22,46 +22,74 @@ defmodule Phoenix.LiveView.AsyncResult do socket |> assign(:foo, "bar") |> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end) - |> assign_async(:profile, [:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} + |> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)} end - Here we are assigning `:org` and `[:profile, :rank]` asynchronously. If no keys are - given (as in the case of `:org`), the keys will default to `[:org]`. The async function - must return a `{:ok, assigns}` or `{:error, reason}` tuple where `assigns` is a map of + 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 under the `@async` assign - on the socket with the name given to `assign_async/3`. It carries the `:loading?`, - `:error`, and `:result` keys. For example, if we wanted to show the loading states - in the UI for the `:org`, our template could conditionally render the states: + The state of the async operation is stored in the socket assigns within an + `%AsyncResult{}`. It carries the loading and error 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...
-
there was an error loading the organization
-
You don't have an org yet
-
<%= org.name%> loaded!
+
Loading organization...
+
<%= org.name %> loaded!
``` - The `async_result` function component can also be used to declaratively + The `with_state` function component can also be used to declaratively render the different states using slots: ```heex - <.async_result :let={org} assign={@async.org}> + <:loading>Loading organization... <:empty>You don't have an organization yet - <:failed>there was an error loading the organization + <:error :let={{_kind, _reason}}>there was an error loading the organization + <:canceled :let={_reason}>loading canceled <%= org.name %> - <.async_result> + ``` Additionally, for async assigns which result in a list of items, you - can consume the `@async.` directly, and it will only enumerate + can consume the assign directly. It will only enumerate the results once the results are loaded. For example: ```heex -
<%= org.name %>
+
<%= 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.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end) + end + + def handle_async(:org, {:ok, fetched_org}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} + end + + def handle_async(:org, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + end + + `start_async/3` is used to fetch the organization asynchronously. The + `handle_async/3` callback is called when the task completes or exists, + 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. ''' use Phoenix.Component @@ -76,6 +104,10 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ TODO """ + def new(name) do + new(name, [name]) + end + def new(name, keys) do loading(%AsyncResult{name: name, keys: keys, result: nil, ok?: false}) end @@ -90,8 +122,8 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ TODO """ - def canceled(%AsyncResult{} = result) do - %AsyncResult{result | state: :canceled} + def canceled(%AsyncResult{} = result, reason) do + error(result, {:canceled, reason}) end @doc """ @@ -124,27 +156,30 @@ defmodule Phoenix.LiveView.AsyncResult do <:loading>Loading organization... <:empty>You don't have an organization yet - <:error :let={_reason}>there was an error loading the organization - <:canceled :let={_reason}>loading cancled + <:error :let={{_kind, _reason}}>there was an error loading the organization + <:canceled :let={_reason}>loading canceled <%= org.name %> ``` """ attr :assign, :any, required: true slot :loading + + # TODO decide if we want an canceled slot slot :canceled + # TODO decide if we want an empty slot slot :empty, doc: "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." - slot :error, + slot :failed, doc: - "rendered when an error is caught or the function return `{:error, reason}`. Receives the error as a :let." + "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." def with_state(assigns) do case assigns.assign do - %AsyncResult{state: :ok, result: result} -> + %AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? -> if assigns.empty != [] && result in [nil, []] do ~H|<%= render_slot(@empty, @assign.result) %>| else @@ -154,11 +189,16 @@ defmodule Phoenix.LiveView.AsyncResult do %AsyncResult{state: :loading} -> ~H|<%= render_slot(@loading) %>| - %AsyncResult{state: :canceled} -> - ~H|<%= render_slot(@canceled) %>| + %AsyncResult{state: {:error, {:canceled, reason}}} -> + if assigns.canceled != [] do + assigns = Phoenix.Component.assign(assigns, reason: reason) + ~H|<%= render_slot(@canceled, @reason) %>| + else + ~H|<%= render_slot(@failed, @assign.state) %>| + end %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> - ~H|<%= render_slot(@error, @assign.state) %>| + ~H|<%= render_slot(@failed, @assign.state) %>| end end @@ -201,7 +241,13 @@ defmodule Phoenix.LiveView.AsyncResult do keys |> Enum.reduce(socket, fn key, acc -> - Phoenix.Component.assign(acc, key, AsyncResult.new(key, keys)) + async_result = + case acc.assigns do + %{^key => %AsyncResult{ok?: true} = existing} -> existing + %{} -> AsyncResult.new(key, keys) + end + + Phoenix.Component.assign(acc, key, async_result) end) |> run_async_task(keys, wrapped_func, fn new_socket, _component_mod, result -> assign_result(new_socket, keys, result) @@ -242,15 +288,30 @@ defmodule Phoenix.LiveView.AsyncResult do end @doc """ - Starts an ansynchronous task. + Starts an ansynchronous task and invokes callback to handle the result. - The task is linked to the caller and errors are wrapped. + 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.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end) + end + def handle_async(:org, {:ok, fetched_org}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} + end + + def handle_async(:org, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + end """ def start_async(%Socket{} = socket, name, func) when is_atom(name) and is_function(func, 0) do @@ -341,19 +402,21 @@ defmodule Phoenix.LiveView.AsyncResult do {:noreply, cancel_async(socket, :preview)} end """ - def cancel_async(%Socket{} = socket, %AsyncResult{} = result) do + def cancel_async(socket, async_or_keys, reason \\ nil) + + def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do result.keys |> Enum.reduce(socket, fn key, acc -> - Phoenix.Component.assign(acc, key, AsyncResult.canceled(result)) + Phoenix.Component.assign(acc, key, AsyncResult.canceled(result, reason)) end) |> cancel_async(result.keys) end - def cancel_async(%Socket{} = socket, key) when is_atom(key) do + def cancel_async(%Socket{} = socket, key, _reason) when is_atom(key) do cancel_async(socket, [key]) end - def cancel_async(%Socket{} = socket, keys) when is_list(keys) do + def cancel_async(%Socket{} = socket, keys, _reason) when is_list(keys) do case get_private_async(socket, keys) do {_ref, pid} when is_pid(pid) -> Process.unlink(pid) diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 34a6e5daf3..c1de7bfd8c 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -68,7 +68,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - assert render(lv) =~ "data canceled" + assert render(lv) =~ "error: {:canceled, nil}" send(lv.pid, :renew_canceled) diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index b7e9345bbc..bd997aae39 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -351,7 +351,6 @@ defmodule Phoenix.LiveViewTest.AsyncLive do ~H""" <.live_component :if={@lc} module={Phoenix.LiveViewTest.AsyncLive.LC} test={@lc} id="lc" />
data loading...
-
data canceled
no data found
data: <%= inspect(@data.result) %>
<%= with {kind, reason} when kind in [:error, :exit] <- @data.state do %> @@ -441,7 +440,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do <:loading>lc_data loading... <:canceled>lc_data canceled <:empty :let={_res}>no lc_data found - <:error :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> + <:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> lc_data: <%= inspect(data) %>
From 00673723d7d734387e63914c0c39fdd0fd147947 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 16:39:29 -0400 Subject: [PATCH 13/63] Docs --- lib/phoenix_live_view/async_result.ex | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 29138dca45..cc5d35fbf8 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -102,7 +102,9 @@ defmodule Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.{AsyncResult, Socket} @doc """ - TODO + Defines a new async result. + + By default, the state will be `:loading`. """ def new(name) do new(name, [name]) @@ -113,35 +115,38 @@ defmodule Phoenix.LiveView.AsyncResult do end @doc """ - TODO + Updates the state of the result to `:loading` """ def loading(%AsyncResult{} = result) do %AsyncResult{result | state: :loading} end @doc """ - TODO + Updates the state of the result to `{:error, {:canceled, reason}}`. """ def canceled(%AsyncResult{} = result, reason) do error(result, {:canceled, reason}) end @doc """ - TODO + Updates the state of the result to `{:error, reason}`. """ def error(%AsyncResult{} = result, reason) do %AsyncResult{result | state: {:error, reason}} end @doc """ - TODO + Updates the state of the result to `{:exit, reason}`. """ def exit(%AsyncResult{} = result, reason) do %AsyncResult{result | state: {:exit, reason}} end @doc """ - TODO + Updates the state of the result to `:ok` and sets the 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. """ def ok(%AsyncResult{} = result, value) do %AsyncResult{result | state: :ok, ok?: true, result: value} From 4bc97e7f31cc245f9d2aa4105e8e003cbe9497d5 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 16:41:12 -0400 Subject: [PATCH 14/63] Docs --- lib/phoenix_live_view/async_result.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index cc5d35fbf8..521dbd03e0 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -1,16 +1,16 @@ defmodule Phoenix.LiveView.AsyncResult do @moduledoc ~S''' - Adds async_assign functionality to LiveViews and LiveComponents. + Adds async functionality to LiveViews and LiveComponents. 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. 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 - error and translate it to a meaningful update in the UI rather than crashing - the user experience. + errors or exits and translate it to a meaningful update in the UI rather than + crashing the user experience. - ## Examples + ## 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. From 1b0b7952337dcf04f5fe15e5400bc1014f78b7c7 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 16:55:18 -0400 Subject: [PATCH 15/63] Fixup --- .formatter.exs | 13 ------------- lib/phoenix_live_view/async_result.ex | 12 +++++++----- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index 212763e89e..47616780b5 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,17 +1,4 @@ -locals_without_parens = [ - attr: 2, - attr: 3, - live: 2, - live: 3, - live: 4, - on_mount: 1, - slot: 1, - slot: 2, - slot: 3 -] - [ - locals_without_parens: locals_without_parens, import_deps: [:phoenix], inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 521dbd03e0..3ffb847d8f 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -398,14 +398,16 @@ defmodule Phoenix.LiveView.AsyncResult do end @doc """ - Cancels an async assign. + Cancels an async operation. + + Accepts either the `%AsyncResult{}` when using `assign_async/3` or + the keys passed to `start_async/3`. ## Examples - TODO fix docs - def handle_event("cancel_preview", _, socket) do - {:noreply, cancel_async(socket, :preview)} - end + cancel_async(socket, :preview) + cancel_async(socket, [:profile, :rank]) + cancel_async(socket, socket.assigns.preview) """ def cancel_async(socket, async_or_keys, reason \\ nil) From 353e8347f3b1b4fd66fbf90aef01e4da83292e64 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 20:11:14 -0400 Subject: [PATCH 16/63] typo --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index c96ef37b2f..9b7d18d8dc 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -679,7 +679,7 @@ defmodule Phoenix.LiveView do put_private(socket, :myapp_meta, %{foo: "bar"}) - And then retreived: + And then retrieved: socket.private[:myapp_meta] """ From cb35f0f31fb11505faca1ba4f038d51e656b1890 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 11 Aug 2023 20:20:36 -0400 Subject: [PATCH 17/63] Docs --- lib/phoenix_live_view/test/live_view_test.ex | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index edd8dec2a1..dd2647462b 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -922,7 +922,16 @@ defmodule Phoenix.LiveViewTest do end @doc """ - TODO + 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 \\ 100) do pids = @@ -942,7 +951,7 @@ defmodule Phoenix.LiveViewTest do end) end) - case Task.yield(task, timeout) || Task.ignore(task) do + case Task.yield(task, timeout) || Task.shutdown(task) do {:ok, _} -> :ok nil -> raise RuntimeError, "expected async processes to finish within #{timeout}ms" end From 707db471e84d19970a05d8c31e8d7d68939bcb87 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:26:14 -0400 Subject: [PATCH 18/63] Refactor --- lib/phoenix_live_view.ex | 64 ++++- lib/phoenix_live_view/async.ex | 212 ++++++++++++++ lib/phoenix_live_view/async_result.ex | 261 +----------------- lib/phoenix_live_view/channel.ex | 92 ++++-- .../integrations/assign_async_test.exs | 9 + test/support/live_views/general.ex | 17 +- 6 files changed, 370 insertions(+), 285 deletions(-) create mode 100644 lib/phoenix_live_view/async.ex diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 9b7d18d8dc..6aade17081 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -308,7 +308,7 @@ defmodule Phoenix.LiveView do * [Uploads (External)](uploads-external.md) ''' - alias Phoenix.LiveView.{Socket, LiveStream} + alias Phoenix.LiveView.{Socket, LiveStream, Async} @type unsigned_params :: map @@ -1877,4 +1877,66 @@ 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. + """ + 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.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end) + end + + def handle_async(:org, {:ok, fetched_org}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} + end + + def handle_async(:org, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + end + """ + 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. + + Accepts either the `%AsyncResult{}` when using `assign_async/3` or + the keys passed to `start_async/3`. + + ## Examples + + cancel_async(socket, :preview) + cancel_async(socket, :preview, :my_reason) + cancel_async(socket, [:profile, :rank]) + cancel_async(socket, socket.assigns.preview) + """ + def cancel_async(socket, async_or_keys, reason \\ nil) 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..dcc7ab2a20 --- /dev/null +++ b/lib/phoenix_live_view/async.ex @@ -0,0 +1,212 @@ +defmodule Phoenix.LiveView.Async do + @moduledoc false + + alias Phoenix.LiveView.{AsyncResult, Socket, Channel} + + @doc false + def start_async(%Socket{} = socket, name, func) + when is_atom(name) and is_function(func, 0) do + run_async_task(socket, name, func, :start) + end + + @doc false + 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 Map.keys(assigns) -- keys == [] do + {:ok, assigns} + else + raise ArgumentError, """ + expected assign_async to return map of assigns for all keys + in #{inspect(keys)}, but got: #{inspect(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 + + keys + |> Enum.reduce(socket, fn key, acc -> + async_result = + case acc.assigns do + %{^key => %AsyncResult{ok?: true} = existing} -> existing + %{} -> AsyncResult.new(key, keys) + end + + Phoenix.Component.assign(acc, key, async_result) + end) + |> run_async_task(keys, wrapped_func, :assign) + end + + defp run_async_task(%Socket{} = socket, keys, func, kind) do + if Phoenix.LiveView.connected?(socket) do + socket = cancel_existing(socket, keys) + lv_pid = self() + cid = cid(socket) + ref = make_ref() + {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, ref, keys, func, kind) end) + update_private_async(socket, &Map.put(&1, keys, {ref, pid, kind})) + else + socket + end + end + + defp do_async(lv_pid, cid, ref, keys, func, async_kind) do + try do + result = func.() + Channel.report_async_result(lv_pid, async_kind, ref, cid, keys, {:ok, result}) + catch + catch_kind, reason -> + Process.unlink(lv_pid) + caught_result = {:catch, catch_kind, reason, __STACKTRACE__} + Channel.report_async_result(lv_pid, async_kind, ref, cid, keys, caught_result) + :erlang.raise(catch_kind, reason, __STACKTRACE__) + end + end + + @doc false + def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do + result.keys + |> Enum.reduce(socket, fn key, acc -> + Phoenix.Component.assign(acc, key, AsyncResult.canceled(result, reason)) + end) + |> cancel_async(result.keys, reason) + end + + def cancel_async(%Socket{} = socket, key, reason) when is_atom(key) do + cancel_async(socket, [key], reason) + end + + def cancel_async(%Socket{} = socket, keys, _reason) when is_list(keys) do + case get_private_async(socket, keys) do + {_ref, pid, _kind} when is_pid(pid) -> + Process.unlink(pid) + Process.exit(pid, :kill) + update_private_async(socket, &Map.delete(&1, keys)) + + nil -> + raise ArgumentError, "uknown async assign #{inspect(keys)}" + end + end + + @doc false + def handle_async(socket, maybe_component, kind, keys, ref, result) do + case prune_current_async(socket, keys, ref) do + {:ok, pruned_socket} -> + handle_kind(pruned_socket, maybe_component, kind, keys, result) + + :error -> + socket + end + end + + @doc false + def handle_trap_exit(socket, maybe_component, kind, keys, ref, reason) do + {:current_stacktrace, stack} = Process.info(self(), :current_stacktrace) + trapped_result = {:catch, :exit, reason, stack} + handle_async(socket, maybe_component, kind, keys, ref, trapped_result) + end + + defp handle_kind(socket, maybe_component, :start, keys, result) do + callback_mod = maybe_component || socket.view + + normalized_result = + case result do + {:ok, result} -> {:ok, result} + {:catch, kind, reason, stack} -> {:exit, to_exit(kind, reason, stack)} + end + + case callback_mod.handle_async(keys, normalized_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}} -> + Enum.reduce(assigns, socket, fn {key, val}, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) + end) + + {:ok, {:error, reason}} -> + Enum.reduce(keys, socket, fn key, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) + end) + + {:catch, kind, reason, stack} -> + normalized_exit = to_exit(kind, reason, stack) + + Enum.reduce(keys, socket, fn key, acc -> + current_async = get_current_async!(acc, key) + Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, normalized_exit)) + end) + end + end + + # handle race of async being canceled and then reassigned + defp prune_current_async(socket, keys, ref) do + case get_private_async(socket, keys) do + {^ref, _pid, _kind} -> {:ok, update_private_async(socket, &Map.delete(&1, keys))} + {_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, keys) do + socket.private[:phoenix_async][keys] + 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.new(key, key) + %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" + end + end + + defp to_exit(:throw, reason, stack), do: {{:nocatch, reason}, stack} + defp to_exit(:error, reason, stack), do: {reason, stack} + defp to_exit(:exit, reason, _stack), do: reason + + defp cancel_existing(%Socket{} = socket, keys) when is_list(keys) do + if get_private_async(socket, keys) do + Phoenix.LiveView.cancel_async(socket, keys) + else + socket + end + end + + 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 index 3ffb847d8f..0890850e5c 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -99,7 +99,7 @@ defmodule Phoenix.LiveView.AsyncResult do state: :loading, result: nil - alias Phoenix.LiveView.{AsyncResult, Socket} + alias Phoenix.LiveView.AsyncResult @doc """ Defines a new async result. @@ -168,19 +168,21 @@ defmodule Phoenix.LiveView.AsyncResult do ``` """ attr :assign, :any, required: true - slot :loading + slot(:loading) # TODO decide if we want an canceled slot - slot :canceled + slot(:canceled) # TODO decide if we want an empty slot - slot :empty, + slot(:empty, doc: "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." + ) - slot :failed, + slot(:failed, doc: "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." + ) def with_state(assigns) do case assigns.assign do @@ -207,255 +209,6 @@ defmodule Phoenix.LiveView.AsyncResult do 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. - """ - 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 Map.keys(assigns) -- keys == [] do - {:ok, assigns} - else - raise ArgumentError, """ - expected assign_async to return map of assigns for all keys - in #{inspect(keys)}, but got: #{inspect(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 - - keys - |> Enum.reduce(socket, fn key, acc -> - async_result = - case acc.assigns do - %{^key => %AsyncResult{ok?: true} = existing} -> existing - %{} -> AsyncResult.new(key, keys) - end - - Phoenix.Component.assign(acc, key, async_result) - end) - |> run_async_task(keys, wrapped_func, fn new_socket, _component_mod, result -> - assign_result(new_socket, keys, result) - end) - end - - defp assign_result(socket, keys, result) do - case result do - {:ok, {:ok, %{} = assigns}} -> - Enum.reduce(assigns, socket, fn {key, val}, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) - end) - - {:ok, {:error, reason}} -> - Enum.reduce(keys, socket, fn key, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) - end) - - {:catch, kind, reason, stack} -> - normalized_exit = to_exit(kind, reason, stack) - - Enum.reduce(keys, socket, fn key, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, normalized_exit)) - end) - end - 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.new(key, key) - %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" - end - 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.new(:org)) - |> start_async(:my_task, fn -> fetch_org!(id) end) - end - - def handle_async(:org, {:ok, fetched_org}, socket) do - %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} - end - - def handle_async(:org, {:exit, reason}, socket) do - %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} - end - """ - def start_async(%Socket{} = socket, name, func) - when is_atom(name) and is_function(func, 0) do - run_async_task(socket, [name], func, fn new_socket, component_mod, result -> - callback_mod = component_mod || new_socket.view - - normalized_result = - case result do - {:ok, result} -> {:ok, result} - {:catch, kind, reason, stack} -> {:exit, to_exit(kind, reason, stack)} - end - - case callback_mod.handle_async(name, normalized_result, new_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) - end - - defp run_async_task(%Socket{} = socket, keys, func, result_func) - when is_list(keys) and is_function(result_func, 3) do - if Phoenix.LiveView.connected?(socket) do - socket = cancel_existing(socket, keys) - lv_pid = self() - cid = cid(socket) - ref = make_ref() - {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, ref, keys, func, result_func) end) - update_private_async(socket, &Map.put(&1, keys, {ref, pid})) - else - socket - end - end - - defp do_async(lv_pid, cid, ref, keys, func, result_func) do - try do - result = func.() - - Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> - handle_current_async(socket, keys, ref, component_mod, {:ok, result}, result_func) - end) - catch - kind, reason -> - Process.unlink(lv_pid) - caught_result = {:catch, kind, reason, __STACKTRACE__} - - Phoenix.LiveView.Channel.write_socket(lv_pid, cid, fn socket, component_mod -> - handle_current_async(socket, keys, ref, component_mod, caught_result, result_func) - end) - - :erlang.raise(kind, reason, __STACKTRACE__) - end - end - - defp to_exit(:throw, reason, stack), do: {{:nocatch, reason}, stack} - defp to_exit(:error, reason, stack), do: {reason, stack} - defp to_exit(:exit, reason, _stack), do: reason - - # handle race of async being canceled and then reassigned - defp handle_current_async(socket, keys, ref, component_mod, result, result_func) - when is_function(result_func, 3) do - case get_private_async(socket, keys) do - {^ref, _pid} -> - new_socket = update_private_async(socket, &Map.delete(&1, keys)) - result_func.(new_socket, component_mod, result) - - {_ref, _pid} -> - socket - - nil -> - socket - end - end - - @doc """ - Cancels an async operation. - - Accepts either the `%AsyncResult{}` when using `assign_async/3` or - the keys passed to `start_async/3`. - - ## Examples - - cancel_async(socket, :preview) - cancel_async(socket, [:profile, :rank]) - cancel_async(socket, socket.assigns.preview) - """ - def cancel_async(socket, async_or_keys, reason \\ nil) - - def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do - result.keys - |> Enum.reduce(socket, fn key, acc -> - Phoenix.Component.assign(acc, key, AsyncResult.canceled(result, reason)) - end) - |> cancel_async(result.keys) - end - - def cancel_async(%Socket{} = socket, key, _reason) when is_atom(key) do - cancel_async(socket, [key]) - end - - def cancel_async(%Socket{} = socket, keys, _reason) when is_list(keys) do - case get_private_async(socket, keys) do - {_ref, pid} when is_pid(pid) -> - Process.unlink(pid) - Process.exit(pid, :kill) - update_private_async(socket, &Map.delete(&1, keys)) - - nil -> - raise ArgumentError, "uknown async assign #{inspect(keys)}" - 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, keys) do - socket.private[:phoenix_async][keys] - end - - defp cancel_existing(%Socket{} = socket, keys) when is_list(keys) do - if get_private_async(socket, keys) do - cancel_async(socket, keys) - else - socket - end - end - - defp cid(%Socket{} = socket) do - if myself = socket.assigns[:myself], do: myself.cid - end - defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.AsyncResult diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index 83b2d9c24c..a54aa70657 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,8 +41,9 @@ defmodule Phoenix.LiveView.Channel do ) end - def write_socket(lv_pid, cid, func) when is_function(func, 2) do - GenServer.call(lv_pid, {@prefix, :write_socket, cid, func}) + def report_async_result(lv_pid, kind, ref, cid, keys, result) + when is_pid(lv_pid) and kind in [:assign, :start] and is_reference(ref) do + send(lv_pid, {@prefix, :async_result, {kind, {ref, cid, keys, result}}}) end def async_pids(lv_pid) do @@ -70,6 +82,24 @@ defmodule Phoenix.LiveView.Channel do e -> reraise(e, __STACKTRACE__) end + def handle_info({:EXIT, pid, reason} = msg, state) do + case Map.fetch(all_asyncs(state), pid) do + {:ok, {keys, ref, cid, kind}} -> + 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) + + msg + |> view_handle_info(new_state.socket) + |> handle_result({:handle_info, 2, nil}, new_state) + + :error -> + {:noreply, state} + end + end + def handle_info({:DOWN, ref, _, _, _reason}, ref) do {:stop, {:shutdown, :closed}, ref} end @@ -231,6 +261,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 @@ -291,25 +333,8 @@ defmodule Phoenix.LiveView.Channel do end def handle_call({@prefix, :async_pids}, _from, state) do - %{socket: socket} = state - lv_pids = get_async_pids(socket.private) - - component_pids = - state - |> component_privates() - |> Enum.flat_map(fn {_cid, private} -> get_async_pids(private) 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, maybe_component -> - %Phoenix.LiveView.Socket{} = new_socket = func.(socket, maybe_component) - {new_socket, {:ok, nil, state}} - end) - - {:reply, :ok, new_state} + pids = state |> all_asyncs() |> Map.keys() + {:reply, {:ok, pids}, state} end def handle_call({@prefix, :fetch_upload_config, name, cid}, _from, state) do @@ -1422,18 +1447,29 @@ defmodule Phoenix.LiveView.Channel do defp maybe_subscribe_to_live_reload(response), do: response - defp component_privates(state) do + defp component_asyncs(state) do %{components: {components, _ids, _}} = state - Enum.into(components, %{}, fn {cid, {_mod, _id, _assigns, private, _prints}} -> - {cid, private} + Enum.reduce(components, %{}, fn {cid, {_mod, _id, _assigns, private, _prints}}, acc -> + Map.merge(acc, socket_asyncs(private, cid)) end) end - defp get_async_pids(private) do + 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.flat_map(ref_pids, fn {_key, {_ref, pid}} -> [pid] end) - %{} -> [] + %{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/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index c1de7bfd8c..dea6a8e600 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -83,6 +83,15 @@ defmodule Phoenix.LiveView.AssignAsyncTest do 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, "/async?test=trap_exit") + + assert render_async(lv, 200) =~ "exit: :boom" + assert render(lv) + assert_receive {:exit, _pid, :boom}, 500 + end end describe "LiveComponent assign_async" do diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index bd997aae39..e3b0d5e92a 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -339,7 +339,6 @@ end defmodule Phoenix.LiveViewTest.AsyncLive do use Phoenix.LiveView - import Phoenix.LiveView.AsyncResult on_mount({__MODULE__, :defaults}) @@ -405,6 +404,16 @@ defmodule Phoenix.LiveViewTest.AsyncLive do 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)} @@ -416,6 +425,11 @@ defmodule Phoenix.LiveViewTest.AsyncLive 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 -> @@ -428,7 +442,6 @@ end defmodule Phoenix.LiveViewTest.AsyncLive.LC do use Phoenix.LiveComponent alias Phoenix.LiveView.AsyncResult - import Phoenix.LiveView.AsyncResult def render(assigns) do ~H""" From c3be3a16141a35a583107d5800cf71dfc9b62192 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:32:10 -0400 Subject: [PATCH 19/63] Move some things around --- lib/phoenix_component.ex | 59 +++++++++- lib/phoenix_live_view/async_result.ex | 151 +------------------------- test/support/live_views/general.ex | 5 +- 3 files changed, 63 insertions(+), 152 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index d1e330f2c6..7456be3737 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -511,7 +511,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] @@ -2868,4 +2868,61 @@ defmodule Phoenix.Component do %><% end %> """ end + + @doc """ + Renders an async assign with slots for the different loading states. + + ## Examples + + ```heex + <.async_result :let={org} assign={@org}> + <:loading>Loading organization... + <:empty>You don't have an organization yet + <:error :let={{_kind, _reason}}>there was an error loading the organization + <:canceled :let={_reason}>loading canceled + <%= org.name %> + <.async_result> + ``` + """ + attr.(:assign, :any, required: true) + slot.(:loading, doc: "rendered while the assign is loading") + + # TODO decide if we want an canceled slot + slot.(:canceled, dock: "rendered when the assign is canceled") + + # TODO decide if we want an empty slot + slot.(:empty, + doc: + "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." + ) + + slot.(:failed, + doc: + "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." + ) + + def async_result(assigns) do + case assigns.assign do + %AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? -> + if assigns.empty != [] && result in [nil, []] do + ~H|<%= render_slot(@empty, @assign.result) %>| + else + ~H|<%= render_slot(@inner_block, @assign.result) %>| + end + + %AsyncResult{state: :loading} -> + ~H|<%= render_slot(@loading) %>| + + %AsyncResult{state: {:error, {:canceled, reason}}} -> + if assigns.canceled != [] do + assigns = Phoenix.Component.assign(assigns, reason: reason) + ~H|<%= render_slot(@canceled, @reason) %>| + else + ~H|<%= render_slot(@failed, @assign.state) %>| + end + + %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> + ~H|<%= render_slot(@failed, @assign.state) %>| + end + end end diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 0890850e5c..c876520cea 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -1,97 +1,9 @@ defmodule Phoenix.LiveView.AsyncResult do @moduledoc ~S''' - Adds async functionality to LiveViews and LiveComponents. - - 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. 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 error 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 `with_state` function component can also be used to declaratively - render the different states using slots: - - ```heex - - <:loading>Loading organization... - <:empty>You don't have an organization yet - <:error :let={{_kind, _reason}}>there was an error loading the organization - <:canceled :let={_reason}>loading canceled - <%= org.name %> - - ``` - - Additionally, for async assigns which result in a list of items, you - can consume 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.new(:org)) - |> start_async(:my_task, fn -> fetch_org!(id) end) - end - - def handle_async(:org, {:ok, fetched_org}, socket) do - %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} - end - - def handle_async(:org, {:exit, reason}, socket) do - %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} - end - - `start_async/3` is used to fetch the organization asynchronously. The - `handle_async/3` callback is called when the task completes or exists, - 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. + Provides a datastructure for tracking the state of an async assign. + + See the `Async Operations` section of the `Phoenix.LiveView` docs for more information. ''' - use Phoenix.Component defstruct name: nil, keys: [], @@ -152,63 +64,6 @@ defmodule Phoenix.LiveView.AsyncResult do %AsyncResult{result | state: :ok, ok?: true, result: value} end - @doc """ - Renders an async assign with slots for the different loading states. - - ## Examples - - ```heex - - <:loading>Loading organization... - <:empty>You don't have an organization yet - <:error :let={{_kind, _reason}}>there was an error loading the organization - <:canceled :let={_reason}>loading canceled - <%= org.name %> - - ``` - """ - attr :assign, :any, required: true - slot(:loading) - - # TODO decide if we want an canceled slot - slot(:canceled) - - # TODO decide if we want an empty slot - slot(:empty, - doc: - "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." - ) - - slot(:failed, - doc: - "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." - ) - - def with_state(assigns) do - case assigns.assign do - %AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? -> - if assigns.empty != [] && result in [nil, []] do - ~H|<%= render_slot(@empty, @assign.result) %>| - else - ~H|<%= render_slot(@inner_block, @assign.result) %>| - end - - %AsyncResult{state: :loading} -> - ~H|<%= render_slot(@loading) %>| - - %AsyncResult{state: {:error, {:canceled, reason}}} -> - if assigns.canceled != [] do - assigns = Phoenix.Component.assign(assigns, reason: reason) - ~H|<%= render_slot(@canceled, @reason) %>| - else - ~H|<%= render_slot(@failed, @assign.state) %>| - end - - %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> - ~H|<%= render_slot(@failed, @assign.state) %>| - end - end - defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.AsyncResult diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index e3b0d5e92a..b96e219e56 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -441,7 +441,6 @@ end defmodule Phoenix.LiveViewTest.AsyncLive.LC do use Phoenix.LiveComponent - alias Phoenix.LiveView.AsyncResult def render(assigns) do ~H""" @@ -449,14 +448,14 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do <%= if @enum do %>
<%= i %>
<% end %> - + <.async_result :let={data} assign={@lc_data}> <:loading>lc_data loading... <:canceled>lc_data canceled <:empty :let={_res}>no lc_data found <:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> lc_data: <%= inspect(data) %> - + """ end From 28efff2f3103d3c08c288a6690745215be238b19 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:46:32 -0400 Subject: [PATCH 20/63] Update lib/phoenix_live_view/test/live_view_test.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/test/live_view_test.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index dd2647462b..fe6938c088 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -933,7 +933,7 @@ defmodule Phoenix.LiveViewTest do assert html =~ "loading data..." assert render_async(lv) =~ "data loaded!" """ - def render_async(view_or_element, timeout \\ 100) do + 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}}) From aaa37afb05f21611ed3ee2935492ca8997cf4974 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:46:50 -0400 Subject: [PATCH 21/63] Avoid extra socket ops --- lib/phoenix_live_view/async.ex | 36 ++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index dcc7ab2a20..8dc70cd90f 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -19,13 +19,13 @@ defmodule Phoenix.LiveView.Async do wrapped_func = fn -> case func.() do {:ok, %{} = assigns} -> - if Map.keys(assigns) -- keys == [] do - {:ok, assigns} - else + 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} -> @@ -146,24 +146,30 @@ defmodule Phoenix.LiveView.Async do defp handle_kind(socket, _maybe_component, :assign, keys, result) do case result do {:ok, {:ok, %{} = assigns}} -> - Enum.reduce(assigns, socket, fn {key, val}, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.ok(current_async, val)) - end) + 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}} -> - Enum.reduce(keys, socket, fn key, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.error(current_async, reason)) - end) + new_assigns = + for key <- keys do + {key, AsyncResult.error(get_current_async!(socket, key), reason)} + end + + Phoenix.Component.assign(socket, new_assigns) {:catch, kind, reason, stack} -> normalized_exit = to_exit(kind, reason, stack) - Enum.reduce(keys, socket, fn key, acc -> - current_async = get_current_async!(acc, key) - Phoenix.Component.assign(acc, key, AsyncResult.exit(current_async, normalized_exit)) - end) + new_assigns = + for key <- keys do + {key, AsyncResult.exit(get_current_async!(socket, key), normalized_exit)} + end + + Phoenix.Component.assign(socket, new_assigns) end end From d2a89e071434a0bc65ec156fe827bf568e57a767 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:53:37 -0400 Subject: [PATCH 22/63] Optimize --- lib/phoenix_live_view/async.ex | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 8dc70cd90f..020c657a38 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -39,16 +39,16 @@ defmodule Phoenix.LiveView.Async do end end - keys - |> Enum.reduce(socket, fn key, acc -> - async_result = - case acc.assigns do - %{^key => %AsyncResult{ok?: true} = existing} -> existing - %{} -> AsyncResult.new(key, keys) + new_assigns = + Enum.flat_map(keys, fn key -> + case socket.assigns do + %{^key => %AsyncResult{ok?: true} = _existing} -> [] + %{} -> [{key, AsyncResult.new(key, keys)}] end + end) - Phoenix.Component.assign(acc, key, async_result) - end) + socket + |> Phoenix.Component.assign(new_assigns) |> run_async_task(keys, wrapped_func, :assign) end From 914de49064f8ea8b3dba21094e7f963032ed9bfc Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 11:59:10 -0400 Subject: [PATCH 23/63] Docs --- lib/phoenix_component.ex | 12 --- lib/phoenix_live_view.ex | 90 +++++++++++++++++++ lib/phoenix_live_view/async.ex | 2 +- lib/phoenix_live_view/async_result.ex | 7 -- .../integrations/assign_async_test.exs | 4 +- test/support/live_views/general.ex | 5 +- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 7456be3737..ef174e0d62 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2879,7 +2879,6 @@ defmodule Phoenix.Component do <:loading>Loading organization... <:empty>You don't have an organization yet <:error :let={{_kind, _reason}}>there was an error loading the organization - <:canceled :let={_reason}>loading canceled <%= org.name %> <.async_result> ``` @@ -2887,9 +2886,6 @@ defmodule Phoenix.Component do attr.(:assign, :any, required: true) slot.(:loading, doc: "rendered while the assign is loading") - # TODO decide if we want an canceled slot - slot.(:canceled, dock: "rendered when the assign is canceled") - # TODO decide if we want an empty slot slot.(:empty, doc: @@ -2913,14 +2909,6 @@ defmodule Phoenix.Component do %AsyncResult{state: :loading} -> ~H|<%= render_slot(@loading) %>| - %AsyncResult{state: {:error, {:canceled, reason}}} -> - if assigns.canceled != [] do - assigns = Phoenix.Component.assign(assigns, reason: reason) - ~H|<%= render_slot(@canceled, @reason) %>| - else - ~H|<%= render_slot(@failed, @assign.state) %>| - end - %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> ~H|<%= render_slot(@failed, @assign.state) %>| end diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 6aade17081..5266676d17 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -306,6 +306,96 @@ 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. 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 error 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... + <:empty>You don't have an organization yet + <:error :let={{_kind, _reason}}>there was an error loading the organization + <%= org.name %> + <.async_result> + ``` + + Additionally, for async assigns which result in a list of items, you + can consume 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.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end) + end + + def handle_async(:org, {:ok, fetched_org}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} + end + + def handle_async(:org, {:exit, reason}, socket) do + %{org: org} = socket.assigns + {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + end + + `start_async/3` is used to fetch the organization asynchronously. The + `handle_async/3` callback is called when the task completes or exists, + 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, Async} diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 020c657a38..eb05fc36b8 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -82,7 +82,7 @@ defmodule Phoenix.LiveView.Async do def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do result.keys |> Enum.reduce(socket, fn key, acc -> - Phoenix.Component.assign(acc, key, AsyncResult.canceled(result, reason)) + Phoenix.Component.assign(acc, key, AsyncResult.error(result, reason)) end) |> cancel_async(result.keys, reason) end diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index c876520cea..4e399bb51a 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -33,13 +33,6 @@ defmodule Phoenix.LiveView.AsyncResult do %AsyncResult{result | state: :loading} end - @doc """ - Updates the state of the result to `{:error, {:canceled, reason}}`. - """ - def canceled(%AsyncResult{} = result, reason) do - error(result, {:canceled, reason}) - end - @doc """ Updates the state of the result to `{:error, reason}`. """ diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index dea6a8e600..2f7650f2e4 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -68,7 +68,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - assert render(lv) =~ "error: {:canceled, nil}" + assert render(lv) =~ "error: :cancel" send(lv.pid, :renew_canceled) @@ -155,7 +155,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - assert render(lv) =~ "lc_data canceled" + assert render(lv) =~ "error: :cancel" Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AsyncLive.LC, id: "lc", diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index b96e219e56..47410df485 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -422,7 +422,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do def handle_info(:boom, _socket), do: exit(:boom) def handle_info(:cancel, socket) do - {:noreply, cancel_async(socket, socket.assigns.data)} + {:noreply, cancel_async(socket, socket.assigns.data, :cancel)} end def handle_info({:EXIT, pid, reason}, socket) do @@ -450,7 +450,6 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do <% end %> <.async_result :let={data} assign={@lc_data}> <:loading>lc_data loading... - <:canceled>lc_data canceled <:empty :let={_res}>no lc_data found <:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> @@ -510,7 +509,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do def update(%{action: :boom}, _socket), do: exit(:boom) def update(%{action: :cancel}, socket) do - {:ok, cancel_async(socket, socket.assigns.lc_data)} + {:ok, cancel_async(socket, socket.assigns.lc_data, :cancel)} end def update(%{action: :renew_canceled}, socket) do From bd23bcbee66266002486d8d72c41c25a50165dc2 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 14:36:40 -0400 Subject: [PATCH 24/63] Add start_async tests --- lib/phoenix_live_view/async.ex | 18 +- .../integrations/assign_async_test.exs | 38 ++-- .../integrations/start_async_test.exs | 127 ++++++++++++ test/support/live_views/general.ex | 190 +++++++++++++++++- test/support/router.ex | 3 +- 5 files changed, 342 insertions(+), 34 deletions(-) create mode 100644 test/phoenix_live_view/integrations/start_async_test.exs diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index eb05fc36b8..dacf40de98 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -87,11 +87,7 @@ defmodule Phoenix.LiveView.Async do |> cancel_async(result.keys, reason) end - def cancel_async(%Socket{} = socket, key, reason) when is_atom(key) do - cancel_async(socket, [key], reason) - end - - def cancel_async(%Socket{} = socket, keys, _reason) when is_list(keys) do + def cancel_async(%Socket{} = socket, keys, _reason) do case get_private_async(socket, keys) do {_ref, pid, _kind} when is_pid(pid) -> Process.unlink(pid) @@ -99,7 +95,7 @@ defmodule Phoenix.LiveView.Async do update_private_async(socket, &Map.delete(&1, keys)) nil -> - raise ArgumentError, "uknown async assign #{inspect(keys)}" + raise ArgumentError, "unknown async assign #{inspect(keys)}" end end @@ -187,8 +183,8 @@ defmodule Phoenix.LiveView.Async do Phoenix.LiveView.put_private(socket, :phoenix_async, func.(existing)) end - defp get_private_async(%Socket{} = socket, keys) do - socket.private[:phoenix_async][keys] + defp get_private_async(%Socket{} = socket, key) do + socket.private[:phoenix_async][key] end defp get_current_async!(socket, key) do @@ -204,9 +200,9 @@ defmodule Phoenix.LiveView.Async do defp to_exit(:error, reason, stack), do: {reason, stack} defp to_exit(:exit, reason, _stack), do: reason - defp cancel_existing(%Socket{} = socket, keys) when is_list(keys) do - if get_private_async(socket, keys) do - Phoenix.LiveView.cancel_async(socket, keys) + defp cancel_existing(%Socket{} = socket, key) do + if get_private_async(socket, key) do + Phoenix.LiveView.cancel_async(socket, key) else socket end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 2f7650f2e4..fef86dbc28 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -13,7 +13,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do describe "LiveView assign_async" do test "bad return", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=bad_return") + {: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"}" @@ -22,7 +22,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "missing known key", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=bad_ok") + {: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]" @@ -31,26 +31,26 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "valid return", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=ok") + {: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, "/async?test=raise") + {: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, "/async?test=exit") + {: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, "/async?test=lv_exit") + {: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)) @@ -61,7 +61,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "cancel_async", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=cancel") + {:ok, lv, _html} = live(conn, "/assign_async?test=cancel") Process.unlink(lv.pid) async_ref = Process.monitor(Process.whereis(:cancel)) send(lv.pid, :cancel) @@ -77,7 +77,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "enum", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=enum") + {:ok, lv, _html} = live(conn, "/assign_async?test=enum") html = render_async(lv, 200) assert html =~ "data: [1, 2, 3]" @@ -86,7 +86,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do test "trapping exits", %{conn: conn} do Process.register(self(), :trap_exit_test) - {:ok, lv, _html} = live(conn, "/async?test=trap_exit") + {:ok, lv, _html} = live(conn, "/assign_async?test=trap_exit") assert render_async(lv, 200) =~ "exit: :boom" assert render(lv) @@ -96,7 +96,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do describe "LiveComponent assign_async" do test "bad return", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=lc_bad_return") + {: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"}" @@ -105,7 +105,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "missing known key", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=lc_bad_ok") + {: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]" @@ -114,26 +114,26 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "valid return", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=lc_ok") + {: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, "/async?test=lc_raise") + {: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, "/async?test=lc_exit") + {: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, "/async?test=lc_lv_exit") + {: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)) @@ -144,11 +144,11 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "cancel_async", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=lc_cancel") + {: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.AsyncLive.LC, + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, id: "lc", action: :cancel ) @@ -157,7 +157,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert render(lv) =~ "error: :cancel" - Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AsyncLive.LC, + Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, id: "lc", action: :renew_canceled ) @@ -167,7 +167,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do end test "enum", %{conn: conn} do - {:ok, lv, _html} = live(conn, "/async?test=lc_enum") + {:ok, lv, _html} = live(conn, "/assign_async?test=lc_enum") html = render_async(lv, 200) assert html =~ "lc_data: [4, 5, 6]" 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..0cef83a706 --- /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} + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom} + 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, :killed} + + 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}, 500 + 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} + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom} + 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, :killed} + + 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/support/live_views/general.ex b/test/support/live_views/general.ex index 47410df485..dcbc8323c5 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -337,7 +337,7 @@ defmodule Phoenix.LiveViewTest.ClassListLive do def render(assigns), do: ~H|Some content| end -defmodule Phoenix.LiveViewTest.AsyncLive do +defmodule Phoenix.LiveViewTest.AssignAsyncLive do use Phoenix.LiveView on_mount({__MODULE__, :defaults}) @@ -348,7 +348,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do def render(assigns) do ~H""" - <.live_component :if={@lc} module={Phoenix.LiveViewTest.AsyncLive.LC} test={@lc} id="lc" /> + <.live_component :if={@lc} module={Phoenix.LiveViewTest.AssignAsyncLive.LC} test={@lc} id="lc" />
data loading...
no data found
data: <%= inspect(@data.result) %>
@@ -406,6 +406,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do 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) @@ -439,7 +440,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do end end -defmodule Phoenix.LiveViewTest.AsyncLive.LC do +defmodule Phoenix.LiveViewTest.AssignAsyncLive.LC do use Phoenix.LiveComponent def render(assigns) do @@ -520,3 +521,186 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do 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, :cancel)} + 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, :cancel)} + 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 fc56c3bab7..3c8babfa25 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -45,7 +45,8 @@ defmodule Phoenix.LiveViewTest.Router do live "/log-disabled", WithLogDisabled live "/errors", ErrorsLive live "/live-reload", ReloadLive - live "/async", AsyncLive + live "/assign_async", AssignAsyncLive + live "/start_async", StartAsyncLive # controller test get "/controller/:type", Controller, :incoming From 6896f0d7c44d6e62c412652983fbdd157dffb021 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 15:39:09 -0400 Subject: [PATCH 25/63] Formatting --- lib/phoenix_live_view.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 5266676d17..b0c87756d2 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -375,9 +375,9 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, - socket - |> assign(:org, AsyncResult.new(:org)) - |> start_async(:my_task, fn -> fetch_org!(id) end) + socket + |> assign(:org, AsyncResult.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end)} end def handle_async(:org, {:ok, fetched_org}, socket) do From 8e6a32dfb4307242980ddbaa35878dd808416128 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 15:44:32 -0400 Subject: [PATCH 26/63] optimize --- lib/phoenix_live_view/async.ex | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index dacf40de98..dbc25b0141 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -80,10 +80,10 @@ defmodule Phoenix.LiveView.Async do @doc false def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do - result.keys - |> Enum.reduce(socket, fn key, acc -> - Phoenix.Component.assign(acc, key, AsyncResult.error(result, reason)) - end) + new_assigns = for key <- result.keys, do: {key, AsyncResult.error(result, reason)} + + socket + |> Phoenix.Component.assign(new_assigns) |> cancel_async(result.keys, reason) end From 05e9e81675cbe4cd11bfcc5c8dc86057204e6f9a Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 15:45:59 -0400 Subject: [PATCH 27/63] fixup --- lib/phoenix_live_view/async.ex | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index dbc25b0141..5faf90c314 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -3,13 +3,11 @@ defmodule Phoenix.LiveView.Async do alias Phoenix.LiveView.{AsyncResult, Socket, Channel} - @doc false def start_async(%Socket{} = socket, name, func) when is_atom(name) and is_function(func, 0) do run_async_task(socket, name, func, :start) end - @doc false 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 @@ -78,7 +76,6 @@ defmodule Phoenix.LiveView.Async do end end - @doc false def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do new_assigns = for key <- result.keys, do: {key, AsyncResult.error(result, reason)} @@ -99,7 +96,6 @@ defmodule Phoenix.LiveView.Async do end end - @doc false def handle_async(socket, maybe_component, kind, keys, ref, result) do case prune_current_async(socket, keys, ref) do {:ok, pruned_socket} -> @@ -110,7 +106,6 @@ defmodule Phoenix.LiveView.Async do end end - @doc false def handle_trap_exit(socket, maybe_component, kind, keys, ref, reason) do {:current_stacktrace, stack} = Process.info(self(), :current_stacktrace) trapped_result = {:catch, :exit, reason, stack} From 5e5ad13537fc501e3700cf725be97b0957c1952f Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 16 Aug 2023 15:47:10 -0400 Subject: [PATCH 28/63] fixup --- lib/phoenix_live_view.ex | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index b0c87756d2..c0449f3699 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -1975,6 +1975,18 @@ defmodule Phoenix.LiveView do 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 @@ -1993,9 +2005,9 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, - socket - |> assign(:org, AsyncResult.new(:org)) - |> start_async(:my_task, fn -> fetch_org!(id) end) + socket + |> assign(:org, AsyncResult.new(:org)) + |> start_async(:my_task, fn -> fetch_org!(id) end) end def handle_async(:org, {:ok, fetched_org}, socket) do @@ -2007,6 +2019,8 @@ defmodule Phoenix.LiveView do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.exit(org, 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 From a1bb44676ae16a038f062c8138a41ea9cd155ae7 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 17 Aug 2023 11:07:01 -0400 Subject: [PATCH 29/63] Use reply_demonitor --- lib/phoenix_live_view/async.ex | 29 ++++++++++++++----------- lib/phoenix_live_view/channel.ex | 36 +++++++++++++++----------------- 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 5faf90c314..4c4fc90109 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -55,24 +55,29 @@ defmodule Phoenix.LiveView.Async do socket = cancel_existing(socket, keys) lv_pid = self() cid = cid(socket) - ref = make_ref() - {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, ref, keys, func, kind) end) + {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, keys, func, kind) end) + ref = :erlang.monitor(:process, pid, alias: :reply_demonitor, tag: {__MODULE__, keys, cid, kind}) + send(pid, {:context, ref}) + update_private_async(socket, &Map.put(&1, keys, {ref, pid, kind})) else socket end end - defp do_async(lv_pid, cid, ref, keys, func, async_kind) do - try do - result = func.() - Channel.report_async_result(lv_pid, async_kind, ref, cid, keys, {:ok, result}) - catch - catch_kind, reason -> - Process.unlink(lv_pid) - caught_result = {:catch, catch_kind, reason, __STACKTRACE__} - Channel.report_async_result(lv_pid, async_kind, ref, cid, keys, caught_result) - :erlang.raise(catch_kind, reason, __STACKTRACE__) + defp do_async(lv_pid, cid, keys, func, async_kind) do + receive do + {:context, ref} -> + try do + result = func.() + Channel.report_async_result(ref, async_kind, ref, cid, keys, {:ok, result}) + catch + catch_kind, reason -> + Process.unlink(lv_pid) + caught_result = {:catch, catch_kind, reason, __STACKTRACE__} + Channel.report_async_result(ref, async_kind, ref, cid, keys, caught_result) + :erlang.raise(catch_kind, reason, __STACKTRACE__) + end end end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index a54aa70657..a79094bbf8 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -41,9 +41,9 @@ defmodule Phoenix.LiveView.Channel do ) end - def report_async_result(lv_pid, kind, ref, cid, keys, result) - when is_pid(lv_pid) and kind in [:assign, :start] and is_reference(ref) do - send(lv_pid, {@prefix, :async_result, {kind, {ref, cid, keys, result}}}) + def report_async_result(lv_pid_or_ref, kind, ref, cid, keys, result) + when kind in [:assign, :start] and is_reference(ref) do + send(lv_pid_or_ref, {@prefix, :async_result, {kind, {ref, cid, keys, result}}}) end def async_pids(lv_pid) do @@ -82,22 +82,10 @@ defmodule Phoenix.LiveView.Channel do e -> reraise(e, __STACKTRACE__) end - def handle_info({:EXIT, pid, reason} = msg, state) do - case Map.fetch(all_asyncs(state), pid) do - {:ok, {keys, ref, cid, kind}} -> - 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) - - msg - |> view_handle_info(new_state.socket) - |> handle_result({:handle_info, 2, nil}, new_state) - - :error -> - {:noreply, state} - end + def handle_info({:EXIT, _pid, _reason} = msg, state) do + msg + |> view_handle_info(state.socket) + |> handle_result({:handle_info, 2, nil}, state) end def handle_info({:DOWN, ref, _, _, _reason}, ref) do @@ -310,6 +298,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) From bbd129536698eedb1aeb67b37cc5771a1435c92e Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 17 Aug 2023 12:22:07 -0400 Subject: [PATCH 30/63] Split state and status --- lib/phoenix_component.ex | 10 +++---- lib/phoenix_live_view.ex | 6 ++-- lib/phoenix_live_view/async.ex | 23 +++++++++----- lib/phoenix_live_view/async_result.ex | 43 ++++++++++++--------------- lib/phoenix_live_view/channel.ex | 6 ++-- test/support/live_views/general.ex | 10 +++---- 6 files changed, 50 insertions(+), 48 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index ef174e0d62..3d9bf12cee 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2899,18 +2899,18 @@ defmodule Phoenix.Component do def async_result(assigns) do case assigns.assign do - %AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? -> + %AsyncResult{status: status, ok?: once_ok?, result: result} when status == :ok or once_ok? -> if assigns.empty != [] && result in [nil, []] do ~H|<%= render_slot(@empty, @assign.result) %>| else ~H|<%= render_slot(@inner_block, @assign.result) %>| end - %AsyncResult{state: :loading} -> - ~H|<%= render_slot(@loading) %>| + %AsyncResult{status: :loading} -> + ~H|<%= render_slot(@loading, @assign.state) %>| - %AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] -> - ~H|<%= render_slot(@failed, @assign.state) %>| + %AsyncResult{status: kind} when kind in [:error, :exit] -> + ~H|<%= render_slot(@failed, {@assign.status, @assign.state}) %>| end end end diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index c0449f3699..e3fe779d35 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -343,7 +343,7 @@ defmodule Phoenix.LiveView do our template could conditionally render the states: ```heex -
Loading organization...
+
Loading organization...
<%= org.name %> loaded!
``` @@ -376,7 +376,7 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, socket - |> assign(:org, AsyncResult.new(:org)) + |> assign(:org, AsyncResult.new()) |> start_async(:my_task, fn -> fetch_org!(id) end)} end @@ -2006,7 +2006,7 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, socket - |> assign(:org, AsyncResult.new(:org)) + |> assign(:org, AsyncResult.new()) |> start_async(:my_task, fn -> fetch_org!(id) end) end diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 4c4fc90109..f1d21f37e6 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -41,7 +41,7 @@ defmodule Phoenix.LiveView.Async do Enum.flat_map(keys, fn key -> case socket.assigns do %{^key => %AsyncResult{ok?: true} = _existing} -> [] - %{} -> [{key, AsyncResult.new(key, keys)}] + %{} -> [{key, AsyncResult.new(keys)}] end end) @@ -56,7 +56,10 @@ defmodule Phoenix.LiveView.Async do lv_pid = self() cid = cid(socket) {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, keys, func, kind) end) - ref = :erlang.monitor(:process, pid, alias: :reply_demonitor, tag: {__MODULE__, keys, cid, kind}) + + ref = + :erlang.monitor(:process, pid, alias: :reply_demonitor, tag: {__MODULE__, keys, cid, kind}) + send(pid, {:context, ref}) update_private_async(socket, &Map.put(&1, keys, {ref, pid, kind})) @@ -82,11 +85,17 @@ defmodule Phoenix.LiveView.Async do end def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do - new_assigns = for key <- result.keys, do: {key, AsyncResult.error(result, reason)} + case result do + %AsyncResult{status: :loading, state: keys} -> + new_assigns = for key <- keys, do: {key, AsyncResult.error(result, reason)} - socket - |> Phoenix.Component.assign(new_assigns) - |> cancel_async(result.keys, reason) + socket + |> Phoenix.Component.assign(new_assigns) + |> cancel_async(keys, reason) + + %AsyncResult{} -> + socket + end end def cancel_async(%Socket{} = socket, keys, _reason) do @@ -191,7 +200,7 @@ defmodule Phoenix.LiveView.Async 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.new(key, key) + %{^key => _other} -> AsyncResult.new(key) %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" end end diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 4e399bb51a..e47dcf5dd2 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -5,10 +5,9 @@ defmodule Phoenix.LiveView.AsyncResult do See the `Async Operations` section of the `Phoenix.LiveView` docs for more information. ''' - defstruct name: nil, - keys: [], - ok?: false, - state: :loading, + defstruct ok?: false, + state: nil, + status: nil, result: nil alias Phoenix.LiveView.AsyncResult @@ -16,56 +15,52 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ Defines a new async result. - By default, the state will be `:loading`. + By default, the state will be `{:loading, []}`. """ - def new(name) do - new(name, [name]) - end - - def new(name, keys) do - loading(%AsyncResult{name: name, keys: keys, result: nil, ok?: false}) + def new(keys \\ []) do + loading(%AsyncResult{result: nil, ok?: false}, keys) end @doc """ - Updates the state of the result to `:loading` + Updates the status of the result to `:loading` """ - def loading(%AsyncResult{} = result) do - %AsyncResult{result | state: :loading} + def loading(%AsyncResult{} = result, keys \\ []) do + %AsyncResult{result | status: :loading, state: keys} end @doc """ - Updates the state of the result to `{:error, reason}`. + Updates the status of the result to `:error` and state to `reason`. """ def error(%AsyncResult{} = result, reason) do - %AsyncResult{result | state: {:error, reason}} + %AsyncResult{result | status: :error, state: reason} end @doc """ - Updates the state of the result to `{:exit, reason}`. + Updates the status of the result to `:exit` and state to `reason`. """ def exit(%AsyncResult{} = result, reason) do - %AsyncResult{result | state: {:exit, reason}} + %AsyncResult{result | status: :exit, state: reason} end @doc """ - Updates the state of the result to `:ok` and sets the result. + Updates the status of the result to `:ok` and sets the 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. """ def ok(%AsyncResult{} = result, value) do - %AsyncResult{result | state: :ok, ok?: true, result: value} + %AsyncResult{result | status: :ok, state: nil, ok?: true, result: value} end defimpl Enumerable, for: Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.AsyncResult - def count(%AsyncResult{result: result, state: :ok}), + def count(%AsyncResult{result: result, status: :ok}), do: Enum.count(result) def count(%AsyncResult{}), do: 0 - def member?(%AsyncResult{result: result, state: :ok}, item) do + def member?(%AsyncResult{result: result, status: :ok}, item) do Enum.member?(result, item) end @@ -74,7 +69,7 @@ defmodule Phoenix.LiveView.AsyncResult do end def reduce( - %AsyncResult{result: result, state: :ok}, + %AsyncResult{result: result, status: :ok}, acc, fun ) do @@ -91,7 +86,7 @@ defmodule Phoenix.LiveView.AsyncResult do do_reduce(tail, fun.(item, acc), fun) end - def slice(%AsyncResult{result: result, state: :ok}) do + def slice(%AsyncResult{result: result, status: :ok}) do fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end end diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index a79094bbf8..884ec58807 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -41,9 +41,9 @@ defmodule Phoenix.LiveView.Channel do ) end - def report_async_result(lv_pid_or_ref, kind, ref, cid, keys, result) - when kind in [:assign, :start] and is_reference(ref) do - send(lv_pid_or_ref, {@prefix, :async_result, {kind, {ref, cid, keys, result}}}) + 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 diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index dcbc8323c5..8f70917cb8 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -349,12 +349,10 @@ defmodule Phoenix.LiveViewTest.AssignAsyncLive do 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) %>
- <%= with {kind, reason} when kind in [:error, :exit] <- @data.state do %> -
<%= kind %>: <%= inspect(reason) %>
- <% end %> +
data loading...
+
no data found
+
data: <%= inspect(@data.result) %>
+
<%= @data.status %>: <%= inspect(@data.state) %>
<%= if @enum do %>
<%= i %>
<% end %> From 6c8386fab479bd68d45fc9e17c2a6169eb8086a3 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 17 Aug 2023 12:32:01 -0400 Subject: [PATCH 31/63] Update lib/phoenix_live_view.ex Co-authored-by: Jason S. --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index e3fe779d35..0f0f976d43 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -311,7 +311,7 @@ defmodule Phoenix.LiveView do 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. For async work, + 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 From 412eb7871fa87015aea8d7e6bdb41b6e5672bd03 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 17 Aug 2023 12:49:12 -0400 Subject: [PATCH 32/63] Set to loading if existing --- lib/phoenix_live_view.ex | 2 +- lib/phoenix_live_view/async.ex | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 0f0f976d43..be09276be4 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -353,7 +353,7 @@ defmodule Phoenix.LiveView do ```heex <.async_result :let={org} assign={@org}> <:loading>Loading organization... - <:empty>You don't have an organization yet + <:empty>You don't have an organization yet <:error :let={{_kind, _reason}}>there was an error loading the organization <%= org.name %> <.async_result> diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index f1d21f37e6..d62fc48156 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -38,10 +38,10 @@ defmodule Phoenix.LiveView.Async do end new_assigns = - Enum.flat_map(keys, fn key -> + Enum.map(keys, fn key -> case socket.assigns do - %{^key => %AsyncResult{ok?: true} = _existing} -> [] - %{} -> [{key, AsyncResult.new(keys)}] + %{^key => %AsyncResult{ok?: true} = existing} -> {key, AsyncResult.loading(existing, keys)} + %{} -> {key, AsyncResult.new(keys)} end end) From bfb7ba42d6c601cae5b06b6a979e856559b85a93 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Sun, 20 Aug 2023 13:56:03 -0400 Subject: [PATCH 33/63] Update lib/phoenix_component.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_component.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 3d9bf12cee..10827b0502 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2900,7 +2900,7 @@ defmodule Phoenix.Component do def async_result(assigns) do case assigns.assign do %AsyncResult{status: status, ok?: once_ok?, result: result} when status == :ok or once_ok? -> - if assigns.empty != [] && result in [nil, []] do + if assigns.empty != [] and (result == nil or Enum.empty?(result)) do ~H|<%= render_slot(@empty, @assign.result) %>| else ~H|<%= render_slot(@inner_block, @assign.result) %>| From 9c9446a9b67f19e937ea0ea87a1c9f54dd448d7c Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:24:16 -0400 Subject: [PATCH 34/63] loading/failed --- lib/phoenix_component.ex | 39 +++++++-------- lib/phoenix_live_view.ex | 15 +++--- lib/phoenix_live_view/async.ex | 12 ++--- lib/phoenix_live_view/async_result.ex | 47 +++++++++---------- .../integrations/assign_async_test.exs | 12 ++--- test/support/live_views/general.ex | 11 +++-- 6 files changed, 64 insertions(+), 72 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 10827b0502..10c9772193 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2877,40 +2877,35 @@ defmodule Phoenix.Component do ```heex <.async_result :let={org} assign={@org}> <:loading>Loading organization... - <:empty>You don't have an organization yet - <:error :let={{_kind, _reason}}>there was an error loading the organization - <%= org.name %> + <:failed :let={reason}>there was an error loading the organization + <%= if org do %> + You don't have an organization yet. + <% else %> + <%= org.name %> + <% end %> <.async_result> ``` """ - attr.(:assign, :any, required: true) + attr.(:assign, AsyncResult, required: true) slot.(:loading, doc: "rendered while the assign is loading") - # TODO decide if we want an empty slot - slot.(:empty, - doc: - "rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let." - ) - slot.(:failed, doc: "rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let." ) - def async_result(assigns) do - case assigns.assign do - %AsyncResult{status: status, ok?: once_ok?, result: result} when status == :ok or once_ok? -> - if assigns.empty != [] and (result == nil or Enum.empty?(result)) do - ~H|<%= render_slot(@empty, @assign.result) %>| - else - ~H|<%= render_slot(@inner_block, @assign.result) %>| - end + slot.(:inner_block, doc: "rendered when the assign is loaded successfully via AsyncResult.ok/2") + + def async_result(%{assign: async_assign} = assigns) do + cond do + async_assign.ok? -> + ~H|<%= render_slot(@inner_block, @assign.result) %>| - %AsyncResult{status: :loading} -> - ~H|<%= render_slot(@loading, @assign.state) %>| + async_assign.loading -> + ~H|<%= render_slot(@loading, @assign.loading) %>| - %AsyncResult{status: kind} when kind in [:error, :exit] -> - ~H|<%= render_slot(@failed, {@assign.status, @assign.state}) %>| + 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 be09276be4..0bf677a7ec 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -343,7 +343,7 @@ defmodule Phoenix.LiveView do our template could conditionally render the states: ```heex -
Loading organization...
+
Loading organization...
<%= org.name %> loaded!
``` @@ -353,8 +353,7 @@ defmodule Phoenix.LiveView do ```heex <.async_result :let={org} assign={@org}> <:loading>Loading organization... - <:empty>You don't have an organization yet - <:error :let={{_kind, _reason}}>there was an error loading the organization + <:failed :let={_reason}>there was an error loading the organization <%= org.name %> <.async_result> ``` @@ -376,7 +375,7 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, socket - |> assign(:org, AsyncResult.new()) + |> assign(:org, AsyncResult.loading()) |> start_async(:my_task, fn -> fetch_org!(id) end)} end @@ -387,7 +386,7 @@ defmodule Phoenix.LiveView do def handle_async(:org, {:exit, reason}, socket) do %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} end `start_async/3` is used to fetch the organization asynchronously. The @@ -1978,7 +1977,7 @@ defmodule Phoenix.LiveView do ## Examples - def mount(%{"slug" => slug}, _, socket) do + }def mount(%{"slug" => slug}, _, socket) do {:ok, socket |> assign(:foo, "bar") @@ -2006,7 +2005,7 @@ defmodule Phoenix.LiveView do def mount(%{"id" => id}, _, socket) do {:ok, socket - |> assign(:org, AsyncResult.new()) + |> assign(:org, AsyncResult.loading()) |> start_async(:my_task, fn -> fetch_org!(id) end) end @@ -2017,7 +2016,7 @@ defmodule Phoenix.LiveView do def handle_async(:org, {:exit, reason}, socket) do %{org: org} = socket.assigns - {:noreply, assign(socket, :org, AsyncResult.exit(org, reason))} + {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} end See the moduledoc for more information. diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index d62fc48156..8e83cbcb2b 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -41,7 +41,7 @@ defmodule Phoenix.LiveView.Async do Enum.map(keys, fn key -> case socket.assigns do %{^key => %AsyncResult{ok?: true} = existing} -> {key, AsyncResult.loading(existing, keys)} - %{} -> {key, AsyncResult.new(keys)} + %{} -> {key, AsyncResult.loading(keys)} end end) @@ -86,8 +86,8 @@ defmodule Phoenix.LiveView.Async do def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do case result do - %AsyncResult{status: :loading, state: keys} -> - new_assigns = for key <- keys, do: {key, AsyncResult.error(result, reason)} + %AsyncResult{loading: keys} when not is_nil(keys) -> + new_assigns = for key <- keys, do: {key, AsyncResult.failed(result, {:exit, reason})} socket |> Phoenix.Component.assign(new_assigns) @@ -161,7 +161,7 @@ defmodule Phoenix.LiveView.Async do {:ok, {:error, reason}} -> new_assigns = for key <- keys do - {key, AsyncResult.error(get_current_async!(socket, key), reason)} + {key, AsyncResult.failed(get_current_async!(socket, key), {:error, reason})} end Phoenix.Component.assign(socket, new_assigns) @@ -171,7 +171,7 @@ defmodule Phoenix.LiveView.Async do new_assigns = for key <- keys do - {key, AsyncResult.exit(get_current_async!(socket, key), normalized_exit)} + {key, AsyncResult.failed(get_current_async!(socket, key), {:exit, normalized_exit})} end Phoenix.Component.assign(socket, new_assigns) @@ -200,7 +200,7 @@ defmodule Phoenix.LiveView.Async 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.new(key) + %{^key => _other} -> AsyncResult.loading(key) %{} -> raise ArgumentError, "missing async assign #{inspect(key)}" end end diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index e47dcf5dd2..e8663d058a 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -6,40 +6,37 @@ defmodule Phoenix.LiveView.AsyncResult do ''' defstruct ok?: false, - state: nil, - status: nil, + loading: nil, + failed: nil, result: nil alias Phoenix.LiveView.AsyncResult @doc """ - Defines a new async result. - - By default, the state will be `{:loading, []}`. + Updates the status of the result to `:loading` """ - def new(keys \\ []) do - loading(%AsyncResult{result: nil, ok?: false}, keys) + def loading do + %AsyncResult{loading: true} end - @doc """ - Updates the status of the result to `:loading` - """ - def loading(%AsyncResult{} = result, keys \\ []) do - %AsyncResult{result | status: :loading, state: keys} + def loading(%AsyncResult{} = result) do + %AsyncResult{result | loading: true, failed: nil} end - @doc """ - Updates the status of the result to `:error` and state to `reason`. - """ - def error(%AsyncResult{} = result, reason) do - %AsyncResult{result | status: :error, state: reason} + 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 status of the result to `:exit` and state to `reason`. + Updates the status of the result to `:error` and state to `reason`. """ - def exit(%AsyncResult{} = result, reason) do - %AsyncResult{result | status: :exit, state: reason} + def failed(%AsyncResult{} = result, reason) do + %AsyncResult{result | failed: reason, loading: nil} end @doc """ @@ -49,18 +46,18 @@ defmodule Phoenix.LiveView.AsyncResult do completed successfully at least once, regardless of future state changes. """ def ok(%AsyncResult{} = result, value) do - %AsyncResult{result | status: :ok, state: nil, ok?: true, result: value} + %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, status: :ok}), + def count(%AsyncResult{result: result, ok?: true}), do: Enum.count(result) def count(%AsyncResult{}), do: 0 - def member?(%AsyncResult{result: result, status: :ok}, item) do + def member?(%AsyncResult{result: result, ok?: true}, item) do Enum.member?(result, item) end @@ -69,7 +66,7 @@ defmodule Phoenix.LiveView.AsyncResult do end def reduce( - %AsyncResult{result: result, status: :ok}, + %AsyncResult{result: result, ok?: true}, acc, fun ) do @@ -86,7 +83,7 @@ defmodule Phoenix.LiveView.AsyncResult do do_reduce(tail, fun.(item, acc), fun) end - def slice(%AsyncResult{result: result, status: :ok}) do + def slice(%AsyncResult{result: result, ok?: true}) do fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index fef86dbc28..2ac2cded02 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -16,7 +16,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest 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"}" + "{:exit, {%ArgumentError{message: "expected assign_async to return {:ok, map} of\\nassigns for [:data] or {:error, reason}, got: 123\\n"}" assert render(lv) end @@ -38,14 +38,14 @@ defmodule Phoenix.LiveView.AssignAsyncTest do 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_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_async(lv) =~ "{:exit, :boom}" assert render(lv) end @@ -68,7 +68,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - assert render(lv) =~ "error: :cancel" + assert render(lv) =~ ":cancel" send(lv.pid, :renew_canceled) @@ -88,7 +88,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest 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_async(lv, 200) =~ "{:exit, :boom}" assert render(lv) assert_receive {:exit, _pid, :boom}, 500 end @@ -155,7 +155,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} - assert render(lv) =~ "error: :cancel" + assert render(lv) =~ "exit: :cancel" Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, id: "lc", diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 8f70917cb8..46d32c1ae0 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -349,10 +349,12 @@ defmodule Phoenix.LiveViewTest.AssignAsyncLive do 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) %>
-
<%= @data.status %>: <%= inspect(@data.state) %>
+ +
data loading...
+
no data found
+
data: <%= inspect(@data.result) %>
+
<%= inspect(@data.failed) %>
+ <%= if @enum do %>
<%= i %>
<% end %> @@ -449,7 +451,6 @@ defmodule Phoenix.LiveViewTest.AssignAsyncLive.LC do <% end %> <.async_result :let={data} assign={@lc_data}> <:loading>lc_data loading... - <:empty :let={_res}>no lc_data found <:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %> lc_data: <%= inspect(data) %> From d33f3de78ee609a0842bc678de55b39fead511fc Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:25:01 -0400 Subject: [PATCH 35/63] typo --- lib/phoenix_component.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 10c9772193..b6f2990346 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2879,9 +2879,9 @@ defmodule Phoenix.Component do <:loading>Loading organization... <:failed :let={reason}>there was an error loading the organization <%= if org do %> - You don't have an organization yet. - <% else %> <%= org.name %> + <% else %> + You don't have an organization yet. <% end %> <.async_result> ``` From fc3c1d671674aafaea84c1f4d8d94e4cee3d2ab4 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:26:45 -0400 Subject: [PATCH 36/63] Fixup --- lib/phoenix_live_view.ex | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 0bf677a7ec..dbfc772c33 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -311,11 +311,11 @@ defmodule Phoenix.LiveView do 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. + 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 From 3f34aeee27f114b81604e0c83896b14df41e6b1a Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:30:01 -0400 Subject: [PATCH 37/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index dbfc772c33..f9583a7b66 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -379,7 +379,7 @@ defmodule Phoenix.LiveView do |> start_async(:my_task, fn -> fetch_org!(id) end)} end - def handle_async(:org, {:ok, fetched_org}, socket) do + def handle_async(:my_task, {:ok, fetched_org}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} end From 67a25315e604efc36f9ac636928bd3d7e21810fb Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:30:27 -0400 Subject: [PATCH 38/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index f9583a7b66..221454b4c0 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -384,7 +384,7 @@ defmodule Phoenix.LiveView do {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} end - def handle_async(:org, {:exit, reason}, socket) do + def handle_async(:my_task, {:exit, reason}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} end From 73e76c3b815e65e6ee8ff9603b79362837e7fa8c Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:31:32 -0400 Subject: [PATCH 39/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 221454b4c0..dbd9c19d85 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -2009,7 +2009,7 @@ defmodule Phoenix.LiveView do |> start_async(:my_task, fn -> fetch_org!(id) end) end - def handle_async(:org, {:ok, fetched_org}, socket) do + def handle_async(:my_task, {:ok, fetched_org}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} end From fb44b75b2fb48bdfd4d3a8a0851a0062d7df2833 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:31:45 -0400 Subject: [PATCH 40/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index dbd9c19d85..f3188b7fa7 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -2014,7 +2014,7 @@ defmodule Phoenix.LiveView do {:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))} end - def handle_async(:org, {:exit, reason}, socket) do + def handle_async(:my_task, {:exit, reason}, socket) do %{org: org} = socket.assigns {:noreply, assign(socket, :org, AsyncResult.failed(org, {:exit, reason}))} end From 6b54a70a59ad6bd1f0b233df0053b192a078c9e9 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 13:40:47 -0400 Subject: [PATCH 41/63] to_exit inside task --- lib/phoenix_live_view.ex | 2 +- lib/phoenix_live_view/async.ex | 26 ++++++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index f3188b7fa7..553c5aa858 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -338,7 +338,7 @@ defmodule Phoenix.LiveView do 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 error states, as well as the result. + `%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: diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 8e83cbcb2b..5d0c8403ec 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -77,7 +77,7 @@ defmodule Phoenix.LiveView.Async do catch catch_kind, reason -> Process.unlink(lv_pid) - caught_result = {:catch, catch_kind, reason, __STACKTRACE__} + caught_result = to_exit(catch_kind, reason, __STACKTRACE__) Channel.report_async_result(ref, async_kind, ref, cid, keys, caught_result) :erlang.raise(catch_kind, reason, __STACKTRACE__) end @@ -121,21 +121,13 @@ defmodule Phoenix.LiveView.Async do end def handle_trap_exit(socket, maybe_component, kind, keys, ref, reason) do - {:current_stacktrace, stack} = Process.info(self(), :current_stacktrace) - trapped_result = {:catch, :exit, reason, stack} - handle_async(socket, maybe_component, kind, keys, ref, trapped_result) + handle_async(socket, maybe_component, kind, keys, ref, {:exit, reason}) end defp handle_kind(socket, maybe_component, :start, keys, result) do callback_mod = maybe_component || socket.view - normalized_result = - case result do - {:ok, result} -> {:ok, result} - {:catch, kind, reason, stack} -> {:exit, to_exit(kind, reason, stack)} - end - - case callback_mod.handle_async(keys, normalized_result, socket) do + case callback_mod.handle_async(keys, result, socket) do {:noreply, %Socket{} = new_socket} -> new_socket @@ -166,12 +158,10 @@ defmodule Phoenix.LiveView.Async do Phoenix.Component.assign(socket, new_assigns) - {:catch, kind, reason, stack} -> - normalized_exit = to_exit(kind, reason, stack) - + {:exit, _reason} = normalized_exit -> new_assigns = for key <- keys do - {key, AsyncResult.failed(get_current_async!(socket, key), {:exit, normalized_exit})} + {key, AsyncResult.failed(get_current_async!(socket, key), normalized_exit)} end Phoenix.Component.assign(socket, new_assigns) @@ -205,9 +195,9 @@ defmodule Phoenix.LiveView.Async do end end - defp to_exit(:throw, reason, stack), do: {{:nocatch, reason}, stack} - defp to_exit(:error, reason, stack), do: {reason, stack} - defp to_exit(:exit, reason, _stack), do: reason + 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 cancel_existing(%Socket{} = socket, key) do if get_private_async(socket, key) do From 9f227b55af37384da32be0d27811e69e6300e6e0 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 14:47:43 -0400 Subject: [PATCH 42/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 553c5aa858..01cd1e9179 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -358,7 +358,7 @@ defmodule Phoenix.LiveView do <.async_result> ``` - Additionally, for async assigns which result in a list of items, you + Additionally, for async assigns which result in a collection of items, you can consume the assign directly. It will only enumerate the results once the results are loaded. For example: From cbf48cc9e66a8311877747308ae27249df1640bb Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 14:48:15 -0400 Subject: [PATCH 43/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 01cd1e9179..fd6d888631 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -359,7 +359,7 @@ defmodule Phoenix.LiveView do ``` Additionally, for async assigns which result in a collection of items, you - can consume the assign directly. It will only enumerate + can enumerate the assign directly. It will only enumerate the results once the results are loaded. For example: ```heex From 42ea838b7ecb19ae3b5c879add9b949e7b24caa0 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 14:54:29 -0400 Subject: [PATCH 44/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index fd6d888631..473c89fd8f 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -390,7 +390,7 @@ defmodule Phoenix.LiveView do end `start_async/3` is used to fetch the organization asynchronously. The - `handle_async/3` callback is called when the task completes or exists, + `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 From 9f3d7764857710b8678385dba6f8c5a5e912e80c Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 14:52:48 -0400 Subject: [PATCH 45/63] Key --- lib/phoenix_live_view/async.ex | 52 +++++++++++++++++----------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 5d0c8403ec..c612bd2b91 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -3,9 +3,9 @@ defmodule Phoenix.LiveView.Async do alias Phoenix.LiveView.{AsyncResult, Socket, Channel} - def start_async(%Socket{} = socket, name, func) - when is_atom(name) and is_function(func, 0) do - run_async_task(socket, name, func, :start) + 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) @@ -50,35 +50,35 @@ defmodule Phoenix.LiveView.Async do |> run_async_task(keys, wrapped_func, :assign) end - defp run_async_task(%Socket{} = socket, keys, func, kind) do + defp run_async_task(%Socket{} = socket, key, func, kind) do if Phoenix.LiveView.connected?(socket) do - socket = cancel_existing(socket, keys) + socket = cancel_existing(socket, key) lv_pid = self() cid = cid(socket) - {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, keys, func, kind) end) + {: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__, keys, cid, kind}) + :erlang.monitor(:process, pid, alias: :reply_demonitor, tag: {__MODULE__, key, cid, kind}) send(pid, {:context, ref}) - update_private_async(socket, &Map.put(&1, keys, {ref, pid, kind})) + update_private_async(socket, &Map.put(&1, key, {ref, pid, kind})) else socket end end - defp do_async(lv_pid, cid, keys, func, async_kind) do + 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, keys, {:ok, result}) + 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, keys, caught_result) + Channel.report_async_result(ref, async_kind, ref, cid, key, caught_result) :erlang.raise(catch_kind, reason, __STACKTRACE__) end end @@ -86,7 +86,7 @@ defmodule Phoenix.LiveView.Async do def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do case result do - %AsyncResult{loading: keys} when not is_nil(keys) -> + %AsyncResult{loading: keys} when is_list(keys) -> new_assigns = for key <- keys, do: {key, AsyncResult.failed(result, {:exit, reason})} socket @@ -98,36 +98,36 @@ defmodule Phoenix.LiveView.Async do end end - def cancel_async(%Socket{} = socket, keys, _reason) do - case get_private_async(socket, keys) do + 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, :kill) - update_private_async(socket, &Map.delete(&1, keys)) + update_private_async(socket, &Map.delete(&1, key)) nil -> - raise ArgumentError, "unknown async assign #{inspect(keys)}" + raise ArgumentError, "unknown async assign #{inspect(key)}" end end - def handle_async(socket, maybe_component, kind, keys, ref, result) do - case prune_current_async(socket, keys, ref) do + 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, keys, result) + handle_kind(pruned_socket, maybe_component, kind, key, result) :error -> socket end end - def handle_trap_exit(socket, maybe_component, kind, keys, ref, reason) do - handle_async(socket, maybe_component, kind, keys, ref, {:exit, reason}) + 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, keys, result) do + defp handle_kind(socket, maybe_component, :start, key, result) do callback_mod = maybe_component || socket.view - case callback_mod.handle_async(keys, result, socket) do + case callback_mod.handle_async(key, result, socket) do {:noreply, %Socket{} = new_socket} -> new_socket @@ -169,9 +169,9 @@ defmodule Phoenix.LiveView.Async do end # handle race of async being canceled and then reassigned - defp prune_current_async(socket, keys, ref) do - case get_private_async(socket, keys) do - {^ref, _pid, _kind} -> {:ok, update_private_async(socket, &Map.delete(&1, keys))} + 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 From 572d457443468aa46b473005e4abeb7e7c010f15 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 15:02:11 -0400 Subject: [PATCH 46/63] public cancel_existing_async --- lib/phoenix_live_view.ex | 21 ++++++++++++++++++++- lib/phoenix_live_view/async.ex | 24 +++++++++++++----------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 473c89fd8f..8ce0f2dea2 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -2032,6 +2032,8 @@ defmodule Phoenix.LiveView do Accepts either the `%AsyncResult{}` when using `assign_async/3` or the keys passed to `start_async/3`. + Returns the `%Phoenix.LiveView.Socket{}`. + ## Examples cancel_async(socket, :preview) @@ -2039,7 +2041,24 @@ defmodule Phoenix.LiveView do cancel_async(socket, [:profile, :rank]) cancel_async(socket, socket.assigns.preview) """ - def cancel_async(socket, async_or_keys, reason \\ nil) do + def cancel_async(socket, async_or_keys, reason \\ :cancel) do Async.cancel_async(socket, async_or_keys, reason) end + + @doc """ + Cancels an async operation if one exists. + + Accepts either the `%AsyncResult{}` when using `assign_async/3` or + the keys passed to `start_async/3`. + + Returns the `%Phoenix.LiveView.Socket{}`. + + ## Examples + + cancel_existing_async(socket, :preview) + cancel_existing_async(socket, socket.assigns.preview) + """ + def cancel_existing_async(socket, async_or_keys, reason \\ :cancel) do + Async.cancel_existing_async(socket, async_or_keys, reason) + end end diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index c612bd2b91..71535ce2f8 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -40,8 +40,11 @@ defmodule Phoenix.LiveView.Async do 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)} + %{^key => %AsyncResult{ok?: true} = existing} -> + {key, AsyncResult.loading(existing, keys)} + + %{} -> + {key, AsyncResult.loading(keys)} end end) @@ -52,7 +55,6 @@ defmodule Phoenix.LiveView.Async do defp run_async_task(%Socket{} = socket, key, func, kind) do if Phoenix.LiveView.connected?(socket) do - socket = cancel_existing(socket, key) lv_pid = self() cid = cid(socket) {:ok, pid} = Task.start_link(fn -> do_async(lv_pid, cid, key, func, kind) end) @@ -110,6 +112,14 @@ defmodule Phoenix.LiveView.Async do end end + def cancel_existing_async(%Socket{} = socket, key, reason) do + if get_private_async(socket, key) do + Phoenix.LiveView.cancel_async(socket, key, reason) + else + 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} -> @@ -199,14 +209,6 @@ defmodule Phoenix.LiveView.Async do defp to_exit(:error, reason, stack), do: {:exit, {reason, stack}} defp to_exit(:exit, reason, _stack), do: {:exit, reason} - defp cancel_existing(%Socket{} = socket, key) do - if get_private_async(socket, key) do - Phoenix.LiveView.cancel_async(socket, key) - else - socket - end - end - defp cid(%Socket{} = socket) do if myself = socket.assigns[:myself], do: myself.cid end From 876888cf4d5b636d5c4d5e96232e611dbde825e1 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 15:15:05 -0400 Subject: [PATCH 47/63] Docs --- lib/phoenix_live_view/async_result.ex | 28 ++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index e8663d058a..6b5f8f02a3 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -3,6 +3,13 @@ defmodule Phoenix.LiveView.AsyncResult do 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, @@ -13,7 +20,13 @@ defmodule Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.AsyncResult @doc """ - Updates the status of the result to `:loading` + Updates the loading state. + + ## Examples + + AsyncResult.loading() + AsyncResult.loading(my_async) + AsyncResult.loading(my_async, %{my: :loading_state}) """ def loading do %AsyncResult{loading: true} @@ -33,17 +46,26 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ - Updates the status of the result to `:error` and state to `reason`. + Updates the failed state. + + ## 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 status of the result to `:ok` and sets the result. + 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. + + ## Examples + + AsyncResult.ok(my_async, my_result) """ def ok(%AsyncResult{} = result, value) do %AsyncResult{result | failed: nil, loading: nil, ok?: true, result: value} From 0cfeda62f30bbe240af0b84ca6b32df1799355d3 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Mon, 21 Aug 2023 15:17:12 -0400 Subject: [PATCH 48/63] Docs --- lib/phoenix_live_view/async_result.ex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 6b5f8f02a3..c9a516a1db 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -22,6 +22,8 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ Updates the loading state. + When loading, the failed state will be reset to `nil`. + ## Examples AsyncResult.loading() @@ -48,6 +50,8 @@ defmodule Phoenix.LiveView.AsyncResult do @doc """ Updates the failed state. + When failed, the loading state will be reset to `nil`. + ## Examples AsyncResult.failed(my_async, {:exit, :boom}) @@ -63,6 +67,8 @@ defmodule Phoenix.LiveView.AsyncResult do 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) From ec83de3a015e28efeaef45ea55f709bc555ab08e Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Tue, 22 Aug 2023 09:23:34 -0400 Subject: [PATCH 49/63] Update lib/phoenix_live_view.ex --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 8ce0f2dea2..59d37af94c 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -1977,7 +1977,7 @@ defmodule Phoenix.LiveView do ## Examples - }def mount(%{"slug" => slug}, _, socket) do + def mount(%{"slug" => slug}, _, socket) do {:ok, socket |> assign(:foo, "bar") From 8439355dd4b353cc6d878bac928616026a3448da Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Tue, 22 Aug 2023 09:32:13 -0400 Subject: [PATCH 50/63] No need for task --- lib/phoenix_live_view/test/live_view_test.ex | 38 ++++++++++++-------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/lib/phoenix_live_view/test/live_view_test.ex b/lib/phoenix_live_view/test/live_view_test.ex index fe6938c088..793c91e68c 100644 --- a/lib/phoenix_live_view/test/live_view_test.ex +++ b/lib/phoenix_live_view/test/live_view_test.ex @@ -933,27 +933,37 @@ defmodule Phoenix.LiveViewTest do 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 + 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 - 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) + 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) - case Task.yield(task, timeout) || Task.shutdown(task) do - {:ok, _} -> :ok - nil -> raise RuntimeError, "expected async processes to finish within #{timeout}ms" + unless Process.cancel_timer(timeout_ref) do + receive do + {^timeout_ref, :timeout} -> :noop + after + 0 -> :noop + end end render(view_or_element) From f4da1edd55fa42dfb29548c7b008c843b9db5423 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Tue, 22 Aug 2023 09:37:46 -0400 Subject: [PATCH 51/63] CI --- test/phoenix_live_view/integrations/start_async_test.exs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/phoenix_live_view/integrations/start_async_test.exs b/test/phoenix_live_view/integrations/start_async_test.exs index 0cef83a706..ae90a23488 100644 --- a/test/phoenix_live_view/integrations/start_async_test.exs +++ b/test/phoenix_live_view/integrations/start_async_test.exs @@ -40,7 +40,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do send(lv.pid, :boom) assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom} - assert_receive {:DOWN, ^async_ref, :process, _pid, :boom} + assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 500 end test "cancel_async", %{conn: conn} do @@ -49,7 +49,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do async_ref = Process.monitor(Process.whereis(:start_async_cancel)) send(lv.pid, :cancel) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} + assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 500 assert render(lv) =~ "result: :loading" From 3a4191ff20c63dd4ad7771a5ee31ea70966c1178 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Tue, 22 Aug 2023 13:13:43 -0400 Subject: [PATCH 52/63] CI --- test/phoenix_live_view/integrations/hooks_test.exs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/phoenix_live_view/integrations/hooks_test.exs b/test/phoenix_live_view/integrations/hooks_test.exs index f77a87b3e7..48362a8c51 100644 --- a/test/phoenix_live_view/integrations/hooks_test.exs +++ b/test/phoenix_live_view/integrations/hooks_test.exs @@ -123,6 +123,8 @@ defmodule Phoenix.LiveView.HooksTest do {:halt, %{}, socket} end) + Process.unlink(lv.pid) + assert ExUnit.CaptureLog.capture_log(fn -> send(lv.pid, :boom) ref = Process.monitor(lv.pid) From 72e9f33b2d53c15b04cfae1b002c3d25668ce9a8 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Tue, 22 Aug 2023 13:26:12 -0400 Subject: [PATCH 53/63] CI --- .../integrations/assign_async_test.exs | 14 ++++++------- .../integrations/hooks_test.exs | 3 ++- .../integrations/start_async_test.exs | 12 +++++------ .../phoenix_live_view/upload/channel_test.exs | 20 +++++++++---------- 4 files changed, 25 insertions(+), 24 deletions(-) diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 2ac2cded02..29d4c1f3d7 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -56,8 +56,8 @@ defmodule Phoenix.LiveView.AssignAsyncTest do async_ref = Process.monitor(Process.whereis(:lv_exit)) send(lv.pid, :boom) - assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom} - assert_receive {:DOWN, ^async_ref, :process, _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 @@ -66,7 +66,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do async_ref = Process.monitor(Process.whereis(:cancel)) send(lv.pid, :cancel) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} + assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 assert render(lv) =~ ":cancel" @@ -90,7 +90,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do assert render_async(lv, 200) =~ "{:exit, :boom}" assert render(lv) - assert_receive {:exit, _pid, :boom}, 500 + assert_receive {:exit, _pid, :boom}, 1000 end end @@ -139,8 +139,8 @@ defmodule Phoenix.LiveView.AssignAsyncTest do async_ref = Process.monitor(Process.whereis(:lc_exit)) send(lv.pid, :boom) - assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom} - assert_receive {:DOWN, ^async_ref, :process, _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 @@ -153,7 +153,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do action: :cancel ) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} + assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 assert render(lv) =~ "exit: :cancel" diff --git a/test/phoenix_live_view/integrations/hooks_test.exs b/test/phoenix_live_view/integrations/hooks_test.exs index 48362a8c51..55859ab5a4 100644 --- a/test/phoenix_live_view/integrations/hooks_test.exs +++ b/test/phoenix_live_view/integrations/hooks_test.exs @@ -123,7 +123,8 @@ defmodule Phoenix.LiveView.HooksTest do {:halt, %{}, socket} end) - Process.unlink(lv.pid) + %{proxy: {_ref, _topic, proxy_pid}} = lv + Process.unlink(proxy_pid) assert ExUnit.CaptureLog.capture_log(fn -> send(lv.pid, :boom) diff --git a/test/phoenix_live_view/integrations/start_async_test.exs b/test/phoenix_live_view/integrations/start_async_test.exs index ae90a23488..e6be74f72b 100644 --- a/test/phoenix_live_view/integrations/start_async_test.exs +++ b/test/phoenix_live_view/integrations/start_async_test.exs @@ -39,8 +39,8 @@ defmodule Phoenix.LiveView.StartAsyncTest do async_ref = Process.monitor(Process.whereis(:start_async_exit)) send(lv.pid, :boom) - assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom} - assert_receive {:DOWN, ^async_ref, :process, _pid, :boom}, 500 + 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 @@ -49,7 +49,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do async_ref = Process.monitor(Process.whereis(:start_async_cancel)) send(lv.pid, :cancel) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 500 + assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 assert render(lv) =~ "result: :loading" @@ -65,7 +65,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do assert render_async(lv, 200) =~ "result: :loading" assert render(lv) - assert_receive {:exit, _pid, :boom}, 500 + assert_receive {:exit, _pid, :boom}, 1000 end end @@ -97,8 +97,8 @@ defmodule Phoenix.LiveView.StartAsyncTest do async_ref = Process.monitor(Process.whereis(:start_async_exit)) send(lv.pid, :boom) - assert_receive {:DOWN, ^lv_ref, :process, _pid, :boom} - assert_receive {:DOWN, ^async_ref, :process, _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 diff --git a/test/phoenix_live_view/upload/channel_test.exs b/test/phoenix_live_view/upload/channel_test.exs index 73788ec6db..ad5bd9da17 100644 --- a/test/phoenix_live_view/upload/channel_test.exs +++ b/test/phoenix_live_view/upload/channel_test.exs @@ -221,7 +221,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] @@ -394,8 +394,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) @@ -423,7 +423,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) @@ -535,8 +535,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 @@ -590,8 +590,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 @@ -631,7 +631,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} @@ -803,7 +803,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}) From 3f59a42750a87652b211387b082d516dcedb4753 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:36:46 -0400 Subject: [PATCH 54/63] Bump --- lib/phoenix_live_view.ex | 27 ++++++------------- lib/phoenix_live_view/async.ex | 14 +++------- .../integrations/assign_async_test.exs | 6 ++--- .../integrations/start_async_test.exs | 4 +-- test/support/live_views/general.ex | 8 +++--- 5 files changed, 20 insertions(+), 39 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 59d37af94c..83766359f2 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -2027,11 +2027,17 @@ defmodule Phoenix.LiveView do end @doc """ - Cancels an async operation. + Cancels an async operation if one exists. Accepts either the `%AsyncResult{}` when using `assign_async/3` or the keys 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 @@ -2041,24 +2047,7 @@ defmodule Phoenix.LiveView do cancel_async(socket, [:profile, :rank]) cancel_async(socket, socket.assigns.preview) """ - def cancel_async(socket, async_or_keys, reason \\ :cancel) do + def cancel_async(socket, async_or_keys, reason \\ {:shutdown, :cancel}) do Async.cancel_async(socket, async_or_keys, reason) end - - @doc """ - Cancels an async operation if one exists. - - Accepts either the `%AsyncResult{}` when using `assign_async/3` or - the keys passed to `start_async/3`. - - Returns the `%Phoenix.LiveView.Socket{}`. - - ## Examples - - cancel_existing_async(socket, :preview) - cancel_existing_async(socket, socket.assigns.preview) - """ - def cancel_existing_async(socket, async_or_keys, reason \\ :cancel) do - Async.cancel_existing_async(socket, async_or_keys, reason) - end end diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index 71535ce2f8..d5d1aa35e0 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -100,23 +100,15 @@ defmodule Phoenix.LiveView.Async do end end - def cancel_async(%Socket{} = socket, key, _reason) do + 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, :kill) + Process.exit(pid, reason) update_private_async(socket, &Map.delete(&1, key)) nil -> - raise ArgumentError, "unknown async assign #{inspect(key)}" - end - end - - def cancel_existing_async(%Socket{} = socket, key, reason) do - if get_private_async(socket, key) do - Phoenix.LiveView.cancel_async(socket, key, reason) - else - socket + socket end end diff --git a/test/phoenix_live_view/integrations/assign_async_test.exs b/test/phoenix_live_view/integrations/assign_async_test.exs index 29d4c1f3d7..c7c8e12d28 100644 --- a/test/phoenix_live_view/integrations/assign_async_test.exs +++ b/test/phoenix_live_view/integrations/assign_async_test.exs @@ -66,7 +66,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do async_ref = Process.monitor(Process.whereis(:cancel)) send(lv.pid, :cancel) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 assert render(lv) =~ ":cancel" @@ -153,9 +153,9 @@ defmodule Phoenix.LiveView.AssignAsyncTest do action: :cancel ) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 - assert render(lv) =~ "exit: :cancel" + assert render(lv) =~ "exit: {:shutdown, :cancel}" Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AssignAsyncLive.LC, id: "lc", diff --git a/test/phoenix_live_view/integrations/start_async_test.exs b/test/phoenix_live_view/integrations/start_async_test.exs index e6be74f72b..90fb7ee744 100644 --- a/test/phoenix_live_view/integrations/start_async_test.exs +++ b/test/phoenix_live_view/integrations/start_async_test.exs @@ -49,7 +49,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do async_ref = Process.monitor(Process.whereis(:start_async_cancel)) send(lv.pid, :cancel) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}, 1000 + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}}, 1000 assert render(lv) =~ "result: :loading" @@ -111,7 +111,7 @@ defmodule Phoenix.LiveView.StartAsyncTest do action: :cancel ) - assert_receive {:DOWN, ^async_ref, :process, _pid, :killed} + assert_receive {:DOWN, ^async_ref, :process, _pid, {:shutdown, :cancel}} assert render(lv) =~ "lc: :loading" diff --git a/test/support/live_views/general.ex b/test/support/live_views/general.ex index 46d32c1ae0..6029eebb5a 100644 --- a/test/support/live_views/general.ex +++ b/test/support/live_views/general.ex @@ -423,7 +423,7 @@ defmodule Phoenix.LiveViewTest.AssignAsyncLive do def handle_info(:boom, _socket), do: exit(:boom) def handle_info(:cancel, socket) do - {:noreply, cancel_async(socket, socket.assigns.data, :cancel)} + {:noreply, cancel_async(socket, socket.assigns.data)} end def handle_info({:EXIT, pid, reason}, socket) do @@ -509,7 +509,7 @@ defmodule Phoenix.LiveViewTest.AssignAsyncLive.LC do def update(%{action: :boom}, _socket), do: exit(:boom) def update(%{action: :cancel}, socket) do - {:ok, cancel_async(socket, socket.assigns.lc_data, :cancel)} + {:ok, cancel_async(socket, socket.assigns.lc_data)} end def update(%{action: :renew_canceled}, socket) do @@ -610,7 +610,7 @@ defmodule Phoenix.LiveViewTest.StartAsyncLive do def handle_info(:boom, _socket), do: exit(:boom) def handle_info(:cancel, socket) do - {:noreply, cancel_async(socket, :result_task, :cancel)} + {:noreply, cancel_async(socket, :result_task)} end def handle_info(:renew_canceled, socket) do @@ -680,7 +680,7 @@ defmodule Phoenix.LiveViewTest.StartAsyncLive.LC do end def update(%{action: :cancel}, socket) do - {:ok, cancel_async(socket, :result_task, :cancel)} + {:ok, cancel_async(socket, :result_task)} end def update(%{action: :renew_canceled}, socket) do From 9c6e97cf3f9f8790779152d1911306cccedc1419 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:37:52 -0400 Subject: [PATCH 55/63] Bump docs --- lib/phoenix_live_view.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 83766359f2..f1cb363216 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -2030,7 +2030,7 @@ defmodule Phoenix.LiveView do Cancels an async operation if one exists. Accepts either the `%AsyncResult{}` when using `assign_async/3` or - the keys passed to `start_async/3`. + 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` @@ -2044,7 +2044,6 @@ defmodule Phoenix.LiveView do cancel_async(socket, :preview) cancel_async(socket, :preview, :my_reason) - cancel_async(socket, [:profile, :rank]) cancel_async(socket, socket.assigns.preview) """ def cancel_async(socket, async_or_keys, reason \\ {:shutdown, :cancel}) do From eaf5a0a997ca92cd8650a8fb5924232524f8e5bc Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:38:29 -0400 Subject: [PATCH 56/63] Update lib/phoenix_live_view/async_result.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/async_result.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index c9a516a1db..8fdb8747c2 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -112,7 +112,7 @@ defmodule Phoenix.LiveView.AsyncResult do end def slice(%AsyncResult{result: result, ok?: true}) do - fn start, length, step -> Enum.slice(result, start..(start + length - 1)//step) end + Enumerable.slice(result) end def slice(%AsyncResult{}) do From e2545927c2b434813ca55028d2702c8f34118841 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:38:44 -0400 Subject: [PATCH 57/63] Update lib/phoenix_live_view/async_result.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/async_result.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 8fdb8747c2..0f27445081 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -86,7 +86,7 @@ defmodule Phoenix.LiveView.AsyncResult do def count(%AsyncResult{}), do: 0 def member?(%AsyncResult{result: result, ok?: true}, item) do - Enum.member?(result, item) + do: Enumerable.member?(result, item) end def member?(%AsyncResult{}, _item) do From aa23281ef4024b20b41d71cf8208e1a410a4d3bb Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:42:30 -0400 Subject: [PATCH 58/63] Update lib/phoenix_live_view/async_result.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/async_result.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index 0f27445081..b9669613da 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -81,7 +81,7 @@ defmodule Phoenix.LiveView.AsyncResult do alias Phoenix.LiveView.AsyncResult def count(%AsyncResult{result: result, ok?: true}), - do: Enum.count(result) + do: Enumerable.count(result) def count(%AsyncResult{}), do: 0 From 844e08af93fa959c529f566efab2d0f7abf23e2e Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:43:39 -0400 Subject: [PATCH 59/63] Update lib/phoenix_live_view/async_result.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/async_result.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index b9669613da..ee70dde326 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -101,7 +101,7 @@ defmodule Phoenix.LiveView.AsyncResult do do_reduce(result, acc, fun) end - def reduce(%AsyncResult{}, acc, _fun), do: acc + def reduce(%AsyncResult{}, {_, acc}, _fun), do: {:done, acc} defp do_reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)} From 9ae5081dc632b110ab71de7afd8cca20053f26c7 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:43:47 -0400 Subject: [PATCH 60/63] Update lib/phoenix_live_view/async_result.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view/async_result.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index ee70dde326..be2f5206c0 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -98,7 +98,7 @@ defmodule Phoenix.LiveView.AsyncResult do acc, fun ) do - do_reduce(result, acc, fun) + Enumerable.reduce(result, acc, fun) end def reduce(%AsyncResult{}, {_, acc}, _fun), do: {:done, acc} From c23041f5fa0643d36fa05eb2ae382bc7b1a8eb77 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:53:19 -0400 Subject: [PATCH 61/63] AsyncResult tests --- lib/phoenix_live_view/async_result.ex | 14 ++--------- test/phoenix_live_view/async_result_test.exs | 25 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 test/phoenix_live_view/async_result_test.exs diff --git a/lib/phoenix_live_view/async_result.ex b/lib/phoenix_live_view/async_result.ex index be2f5206c0..26bc31fa5f 100644 --- a/lib/phoenix_live_view/async_result.ex +++ b/lib/phoenix_live_view/async_result.ex @@ -86,12 +86,10 @@ defmodule Phoenix.LiveView.AsyncResult do def count(%AsyncResult{}), do: 0 def member?(%AsyncResult{result: result, ok?: true}, item) do - do: Enumerable.member?(result, item) + Enumerable.member?(result, item) end - def member?(%AsyncResult{}, _item) do - raise RuntimeError, "cannot lookup member? without an ok result" - end + def member?(%AsyncResult{}, _item), do: false def reduce( %AsyncResult{result: result, ok?: true}, @@ -103,14 +101,6 @@ defmodule Phoenix.LiveView.AsyncResult do def reduce(%AsyncResult{}, {_, acc}, _fun), do: {:done, acc} - defp do_reduce(_list, {:halt, acc}, _fun), do: {:halted, acc} - defp do_reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &do_reduce(list, &1, fun)} - defp do_reduce([], {:cont, acc}, _fun), do: {:done, acc} - - defp do_reduce([item | tail], {:cont, acc}, fun) do - do_reduce(tail, fun.(item, acc), fun) - end - def slice(%AsyncResult{result: result, ok?: true}) do Enumerable.slice(result) end 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 From 4f62548d1f74e4c7a0017e391765383a2cf1b554 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Wed, 23 Aug 2023 16:56:02 -0400 Subject: [PATCH 62/63] Docs --- lib/phoenix_component.ex | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index b6f2990346..bc79d81577 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -2872,6 +2872,10 @@ defmodule Phoenix.Component do @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 @@ -2894,7 +2898,10 @@ defmodule Phoenix.Component do "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") + 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 From 27755a76f0fe8d99134be2939ef7031272c1ec8e Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Fri, 25 Aug 2023 12:43:46 -0400 Subject: [PATCH 63/63] Kill unnecessary clause --- lib/phoenix_live_view/channel.ex | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/phoenix_live_view/channel.ex b/lib/phoenix_live_view/channel.ex index 884ec58807..8f6cd165df 100644 --- a/lib/phoenix_live_view/channel.ex +++ b/lib/phoenix_live_view/channel.ex @@ -82,12 +82,6 @@ defmodule Phoenix.LiveView.Channel do e -> reraise(e, __STACKTRACE__) end - def handle_info({:EXIT, _pid, _reason} = msg, state) do - msg - |> view_handle_info(state.socket) - |> handle_result({:handle_info, 2, nil}, state) - end - def handle_info({:DOWN, ref, _, _, _reason}, ref) do {:stop, {:shutdown, :closed}, ref} end