diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index c9d8e950a7..8ac045bfeb 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -1341,7 +1341,7 @@ defmodule Phoenix.LiveView do lifecycle in order to bind/update assigns, intercept events, patches, and regular messages when necessary, and to inject common functionality. Use `attach_hook/1` on any of the following - lifecycle stages: `:handle_params`, `:handle_event`, `:handle_info`, and + lifecycle stages: `:handle_params`, `:handle_event`, `:handle_info`, `:handle_async`, and `:after_render`. To attach a hook to the `:mount` stage, use `on_mount/1`. > Note: only `:after_render` hooks are currently supported in LiveComponents. diff --git a/lib/phoenix_live_view/async.ex b/lib/phoenix_live_view/async.ex index e67e47a6ee..2b32b8a068 100644 --- a/lib/phoenix_live_view/async.ex +++ b/lib/phoenix_live_view/async.ex @@ -129,16 +129,22 @@ defmodule Phoenix.LiveView.Async do defp handle_kind(socket, maybe_component, :start, key, result) do callback_mod = maybe_component || socket.view - case callback_mod.handle_async(key, result, socket) do - {:noreply, %Socket{} = new_socket} -> - new_socket + case Phoenix.LiveView.Lifecycle.handle_async(key, result, socket) do + {:cont, %Socket{} = socket} -> + case callback_mod.handle_async(key, result, socket) do + {:noreply, %Socket{} = new_socket} -> + new_socket - other -> - raise ArgumentError, """ - expected #{inspect(callback_mod)}.handle_async/3 to return {:noreply, socket}, got: + other -> + raise ArgumentError, """ + expected #{inspect(callback_mod)}.handle_async/3 to return {:noreply, socket}, got: - #{inspect(other)} - """ + #{inspect(other)} + """ + end + + {_, %Socket{} = socket} -> + socket end end diff --git a/lib/phoenix_live_view/lifecycle.ex b/lib/phoenix_live_view/lifecycle.ex index a1d855d4d2..c6e3862d64 100644 --- a/lib/phoenix_live_view/lifecycle.ex +++ b/lib/phoenix_live_view/lifecycle.ex @@ -7,14 +7,20 @@ defmodule Phoenix.LiveView.Lifecycle do @type hook :: map() @type t :: %__MODULE__{ + after_render: [hook], + handle_async: [hook], handle_event: [hook], handle_info: [hook], handle_params: [hook], - after_render: [hook], mount: [hook] } - defstruct handle_event: [], handle_info: [], handle_params: [], mount: [], after_render: [] + defstruct after_render: [], + handle_async: [], + handle_event: [], + handle_info: [], + handle_params: [], + mount: [] @doc """ Returns a map of infos about the lifecycle stage for the given `view`. @@ -31,7 +37,7 @@ defmodule Phoenix.LiveView.Lifecycle do end defp callbacks?(%Socket{private: %{@lifecycle => lifecycle}}, stage) - when stage in [:handle_event, :handle_info, :handle_params, :mount] do + when stage in [:handle_async, :handle_event, :handle_info, :handle_params, :mount] do lifecycle |> Map.fetch!(stage) |> Kernel.!=([]) end @@ -41,7 +47,7 @@ defmodule Phoenix.LiveView.Lifecycle do end def attach_hook(%Socket{} = socket, id, stage, fun) - when stage in [:handle_event, :handle_info, :handle_params, :after_render] do + when stage in [:handle_async, :handle_event, :handle_info, :handle_params, :after_render] do lifecycle = lifecycle(socket, stage) hook = hook!(id, stage, fun) existing = Enum.find(Map.fetch!(lifecycle, stage), &(&1.id == id)) @@ -61,14 +67,14 @@ defmodule Phoenix.LiveView.Lifecycle do raise ArgumentError, """ invalid lifecycle event provided to attach_hook. - Expected one of: :handle_event | :handle_info | :handle_params + Expected one of: :handle_async | :handle_event | :handle_info | :handle_params | :after_render Got: #{inspect(stage)} """ end def detach_hook(%Socket{} = socket, id, stage) - when stage in [:handle_event, :handle_info, :handle_params, :after_render] do + when stage in [:handle_async, :handle_event, :handle_info, :handle_params, :after_render] do update_lifecycle(socket, stage, fn hooks -> for hook <- hooks, hook.id != id, do: hook end) @@ -78,7 +84,7 @@ defmodule Phoenix.LiveView.Lifecycle do raise ArgumentError, """ invalid lifecycle event provided to detach_hook. - Expected one of: :handle_event | :handle_info | :handle_params + Expected one of: :handle_async | :handle_event | :handle_info | :handle_params | :after_render Got: #{inspect(stage)} """ @@ -191,6 +197,13 @@ defmodule Phoenix.LiveView.Lifecycle do end) end + @doc false + def handle_async(key, result, %Socket{private: %{@lifecycle => lifecycle}} = socket) do + reduce_socket(lifecycle.handle_async, socket, fn hook, acc -> + hook.function.(key, result, acc) + end) + end + @doc false def after_render(%Socket{private: %{@lifecycle => lifecycle}} = socket) do {:cont, new_socket} = @@ -224,7 +237,7 @@ defmodule Phoenix.LiveView.Lifecycle do Expected one of: - #{expected_return(hook)} + #{expected_return(hook)} Got: #{inspect(result)} """ diff --git a/test/phoenix_live_view/hooks_test.exs b/test/phoenix_live_view/hooks_test.exs index 5af51c0f94..00c715ef12 100644 --- a/test/phoenix_live_view/hooks_test.exs +++ b/test/phoenix_live_view/hooks_test.exs @@ -30,6 +30,13 @@ defmodule Phoenix.LiveView.IntegrationHooksTest do end end + test "supports handle_async/3" do + assert %Lifecycle{handle_async: [%{id: :noop}]} = + build_socket() + |> LiveView.attach_hook(:noop, :handle_async, &noop/3) + |> lifecycle() + end + test "supports handle_event/3" do assert %Lifecycle{handle_event: [%{id: :noop}]} = build_socket() @@ -62,11 +69,13 @@ defmodule Phoenix.LiveView.IntegrationHooksTest do test "supports named hooks for multiple lifecycle events" do socket = build_socket() + |> LiveView.attach_hook(:noop, :handle_async, &noop/3) |> LiveView.attach_hook(:noop, :handle_params, &noop/3) |> LiveView.attach_hook(:noop, :handle_event, &noop/3) |> LiveView.attach_hook(:noop, :handle_info, &noop/2) assert %Lifecycle{ + handle_async: [%{id: :noop, stage: :handle_async}], handle_info: [%{id: :noop, stage: :handle_info}], handle_event: [%{id: :noop, stage: :handle_event}], handle_params: [%{id: :noop, stage: :handle_params}] diff --git a/test/phoenix_live_view/integrations/hooks_test.exs b/test/phoenix_live_view/integrations/hooks_test.exs index 55859ab5a4..2a22f801cb 100644 --- a/test/phoenix_live_view/integrations/hooks_test.exs +++ b/test/phoenix_live_view/integrations/hooks_test.exs @@ -123,12 +123,10 @@ defmodule Phoenix.LiveView.HooksTest do {:halt, %{}, socket} end) - %{proxy: {_ref, _topic, proxy_pid}} = lv - Process.unlink(proxy_pid) + ref = HooksLive.unlink_and_monitor(lv) assert ExUnit.CaptureLog.capture_log(fn -> send(lv.pid, :boom) - ref = Process.monitor(lv.pid) assert_receive {:DOWN, ^ref, _, _, _} end) =~ "Got: {:halt, %{}, #Phoenix.LiveView.Socket<" end @@ -242,11 +240,55 @@ defmodule Phoenix.LiveView.HooksTest do refute_received {:intercepted, ^ref} end - test "handle_info/3 without module callback", %{conn: conn} do + test "handle_info/2 without module callback", %{conn: conn} do {:ok, lv, _html} = live(conn, "/lifecycle/handle-info-not-defined") assert render(lv) =~ "data=somedata" end + test "handle_async/3 raises when hook result is invalid", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/lifecycle") + + HooksLive.attach_hook(lv, :boom, :handle_async, fn _, _, _ -> :boom end) + + monitor = HooksLive.unlink_and_monitor(lv) + lv |> element("#async") |> render_click() + assert_receive {:DOWN, ^monitor, :process, _pid, {%error{message: msg}, _}} + assert error == ArgumentError + assert msg =~ "Got: :boom" + end + + test "handle_async/3 attached after connected", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/lifecycle") + + HooksLive.attach_hook(lv, :hook, :handle_async, fn _, _, socket -> + {:cont, Component.update(socket, :task, &(&1 <> "o"))} + end) + + lv |> element("#async") |> render_click() + assert render_async(lv) =~ "task:o.\n" + + HooksLive.detach_hook(lv, :hook, :handle_async) + + lv |> element("#async") |> render_click() + assert render_async(lv) =~ "task:o..\n" + end + + test "handle_async/3 halts", %{conn: conn} do + {:ok, lv, _html} = live(conn, "/lifecycle") + + HooksLive.attach_hook(lv, :hook, :handle_async, fn _, _, socket -> + {:halt, Component.update(socket, :task, &(&1 <> "o"))} + end) + + lv |> element("#async") |> render_click() + assert render_async(lv) =~ "task:o\n" + + HooksLive.detach_hook(lv, :hook, :handle_async) + + lv |> element("#async") |> render_click() + assert render_async(lv) =~ "task:o.\n" + end + test "attach_hook raises when given a live component socket", %{conn: conn} do {:ok, lv, _html} = live(conn, "/lifecycle/components") @@ -275,6 +317,12 @@ defmodule Phoenix.LiveView.HooksTest do exported?: true } + assert Lifecycle.stage_info(socket, HooksLive, :handle_async, 3) == %{ + any?: true, + callbacks?: false, + exported?: true + } + assert Lifecycle.stage_info(socket, HooksLive, :handle_params, 3) == %{ any?: false, callbacks?: false, diff --git a/test/support/live_views/lifecycle.ex b/test/support/live_views/lifecycle.ex index 17f466cc16..8bd53e9e23 100644 --- a/test/support/live_views/lifecycle.ex +++ b/test/support/live_views/lifecycle.ex @@ -57,14 +57,16 @@ defmodule Phoenix.LiveViewTest.HooksLive do last_on_mount:<%= inspect(assigns[:last_on_mount]) %> params_hook:<%= assigns[:params_hook_ref] %> count:<%= @count %> + task:<%= @task %> + """ end def mount(_params, _session, socket) do - {:ok, assign(socket, count: 0)} + {:ok, assign(socket, count: 0, task: "")} end def handle_event("inc", _, socket), do: {:noreply, update(socket, :count, &(&1 + 1))} @@ -75,6 +77,14 @@ defmodule Phoenix.LiveViewTest.HooksLive do {:noreply, push_patch(socket, to: "/lifecycle?ref=#{ref}")} end + def handle_event("async", _, socket) do + {:noreply, start_async(socket, :task, fn -> true end)} + end + + def handle_async(:task, {:ok, true}, socket) do + {:noreply, update(socket, :task, &(&1 <> "."))} + end + def handle_call({:run, func}, _, socket), do: func.(socket) def handle_call({:push_patch, to}, _, socket) do @@ -121,6 +131,11 @@ defmodule Phoenix.LiveViewTest.HooksLive do end end + def unlink_and_monitor(lv) do + Process.unlink(proxy_pid(lv)) + Process.monitor(proxy_pid(lv)) + end + def run(lv, func) do GenServer.call(lv.pid, {:run, func}) end