From 358068c49fd2f4ad32e45e7ee7dc3322a9f994a4 Mon Sep 17 00:00:00 2001 From: dvic Date: Tue, 15 Aug 2023 22:31:21 +0200 Subject: [PATCH] Add support for update_many/2 --- lib/phoenix_live_component.ex | 5 +- lib/phoenix_live_view/diff.ex | 103 ++++++++--- lib/phoenix_live_view/renderer.ex | 7 + lib/phoenix_live_view/utils.ex | 60 ++++--- test/phoenix_component/warnings_test.exs | 26 +++ test/phoenix_live_view/diff_test.exs | 211 +++++++++++++++++++++++ 6 files changed, 366 insertions(+), 46 deletions(-) create mode 100644 test/phoenix_component/warnings_test.exs diff --git a/lib/phoenix_live_component.ex b/lib/phoenix_live_component.ex index ed344a1405..270f6410f2 100644 --- a/lib/phoenix_live_component.ex +++ b/lib/phoenix_live_component.ex @@ -515,6 +515,9 @@ defmodule Phoenix.LiveComponent do @callback mount(socket :: Socket.t()) :: {:ok, Socket.t()} | {:ok, Socket.t(), keyword()} + @callback update_many(list_of_assigns :: [Socket.assigns()], sockets :: [Socket.t()]) :: + [Socket.t()] + @callback preload(list_of_assigns :: [Socket.assigns()]) :: list_of_assigns :: [Socket.assigns()] @@ -530,5 +533,5 @@ defmodule Phoenix.LiveComponent do ) :: {:noreply, Socket.t()} | {:reply, map, Socket.t()} - @optional_callbacks mount: 1, preload: 1, update: 2, handle_event: 3 + @optional_callbacks mount: 1, update_many: 2, preload: 1, update: 2, handle_event: 3 end diff --git a/lib/phoenix_live_view/diff.ex b/lib/phoenix_live_view/diff.ex index e79e91b6b5..c60cbfa0a9 100644 --- a/lib/phoenix_live_view/diff.ex +++ b/lib/phoenix_live_view/diff.ex @@ -627,44 +627,97 @@ defmodule Phoenix.LiveView.Diff do {{pending, diffs, components}, seen_ids} = Enum.reduce(pending, acc, fn {component, entries}, acc -> - entries = maybe_preload_components(component, Enum.reverse(entries)) + # reverse order so next reduce returns in original order + # maybe_preload_components might not touch entries if update_many is + # defined, so reverse them before + entries = Enum.reverse(entries) + entries = maybe_preload_components(component, entries) + + {triplet, seen_ids} = acc + # {triplet, seen_ids, entries, sockets, list_of_assigns} + acc = {triplet, seen_ids, [], [], []} + + # first collect sockets and list of assigns, because of update_many/2 + {triplet, seen_ids, entries, sockets, list_of_assigns} = + Enum.reduce(entries, acc, fn {cid, id, _new?, new_assigns} = entry, + {triplet, seen_ids, entries, sockets, list_of_assigns} -> + {pending, diffs, components} = triplet + + seen_ids = ensure_not_seen!(seen_ids, component, id) + + {socket, components} = ensure_component(socket, component, cids, cid, id, components) + + { + {pending, diffs, components}, + seen_ids, + [entry | entries], + [socket | sockets], + [new_assigns | list_of_assigns] + } + end) - Enum.reduce(entries, acc, fn {cid, id, new?, new_assigns}, {triplet, seen_ids} -> - {pending, diffs, components} = triplet + # reverse order so next reduce returns in original order + entries = Enum.reverse(entries) + sockets = Enum.reverse(sockets) + list_of_assigns = Enum.reverse(list_of_assigns) - if Map.has_key?(seen_ids, [component | id]) do - raise "found duplicate ID #{inspect(id)} " <> - "for component #{inspect(component)} when rendering template" + # execute update/2 or update_many/2 + sockets = + if function_exported?(component, :update_many, 2) do + Utils.update_many!(sockets, component, list_of_assigns) + else + sockets + |> Enum.zip(list_of_assigns) + |> Enum.map(fn {socket, assigns} -> + Utils.maybe_call_update!(socket, component, assigns) + end) end - {socket, components} = - case cids do - %{^cid => {_component, _id, assigns, private, prints}} -> - private = Map.delete(private, @marked_for_deletion) - {configure_socket_for_component(socket, assigns, private, prints), components} - - %{} -> - myself_assigns = %{myself: %Phoenix.LiveComponent.CID{cid: cid}} - - {mount_component(socket, component, myself_assigns), - put_cid(components, component, id, cid)} - end + # render components + triplet = + entries + |> Enum.zip(sockets) + |> Enum.reduce(triplet, fn {entry, socket}, triplet -> + {cid, id, new?, _new_assigns} = entry + {pending, diffs, components} = triplet - socket = Utils.maybe_call_update!(socket, component, new_assigns) - diffs = maybe_put_events(diffs, socket) + diffs = maybe_put_events(diffs, socket) - triplet = render_component(socket, component, id, cid, new?, pending, cids, diffs, components) + end) - {triplet, Map.put(seen_ids, [component | id], true)} - end) + {triplet, seen_ids} end) render_pending_components(socket, pending, seen_ids, cids, diffs, components) end + defp ensure_component(socket, component, cids, cid, id, components) do + case cids do + %{^cid => {_component, _id, assigns, private, prints}} -> + private = Map.delete(private, @marked_for_deletion) + {configure_socket_for_component(socket, assigns, private, prints), components} + + %{} -> + myself_assigns = %{myself: %Phoenix.LiveComponent.CID{cid: cid}} + + {mount_component(socket, component, myself_assigns), + put_cid(components, component, id, cid)} + end + end + + defp ensure_not_seen!(seen_ids, component, id) do + if Map.has_key?(seen_ids, [component | id]) do + raise "found duplicate ID #{inspect(id)} " <> + "for component #{inspect(component)} when rendering template" + end + + Map.put(seen_ids, [component | id], true) + end + defp maybe_preload_components(component, entries) do - if function_exported?(component, :preload, 1) do + if function_exported?(component, :preload, 1) and + not function_exported?(component, :update_many, 2) do list_of_assigns = Enum.map(entries, fn {_cid, _id, _new?, new_assigns} -> new_assigns end) result = component.preload(list_of_assigns) zip_preloads(result, entries, component, result) @@ -674,7 +727,7 @@ defmodule Phoenix.LiveView.Diff do end defp maybe_call_preload!(module, assigns) do - if function_exported?(module, :preload, 1) do + if function_exported?(module, :preload, 1) and not function_exported?(module, :update_many, 2) do [new_assigns] = module.preload([assigns]) new_assigns else diff --git a/lib/phoenix_live_view/renderer.ex b/lib/phoenix_live_view/renderer.ex index 13535038fa..72d5c96491 100644 --- a/lib/phoenix_live_view/renderer.ex +++ b/lib/phoenix_live_view/renderer.ex @@ -2,11 +2,18 @@ defmodule Phoenix.LiveView.Renderer do @moduledoc false defmacro __before_compile__(env) do + preload? = Module.defines?(env.module, {:preload, 1}) render? = Module.defines?(env.module, {:render, 1}) root = Path.dirname(env.file) filename = template_filename(env) templates = Phoenix.Template.find_all(root, filename) + if preload? do + IO.warn( + "LiveComponent.preload/1 is deprecated (defind in #{inspect(env.module)}). Use LiveComponent.update_many/2 instead." + ) + end + case {render?, templates} do {true, [template | _]} -> IO.warn( diff --git a/lib/phoenix_live_view/utils.ex b/lib/phoenix_live_view/utils.ex index b60cb09631..564b53ad11 100644 --- a/lib/phoenix_live_view/utils.ex +++ b/lib/phoenix_live_view/utils.ex @@ -479,34 +479,54 @@ defmodule Phoenix.LiveView.Utils do end @doc """ - Calls the optional `update/2` callback, otherwise update the socket directly. + Calls the optional `update/2` or `update_many/2` callback, otherwise update the socket(s) directly. """ def maybe_call_update!(socket, component, assigns) do - if function_exported?(component, :update, 2) do - socket = - case component.update(assigns, socket) do - {:ok, %Socket{} = socket} -> - socket - - other -> - raise ArgumentError, """ - invalid result returned from #{inspect(component)}.update/2. - - Expected {:ok, socket}, got: #{inspect(other)} - """ + cond do + function_exported?(component, :update_many, 2) -> + [socket] = update_many!([socket], component, [assigns]) + socket + + function_exported?(component, :update, 2) -> + socket = + case component.update(assigns, socket) do + {:ok, %Socket{} = socket} -> + socket + + other -> + raise ArgumentError, """ + invalid result returned from #{inspect(component)}.update/2. + + Expected {:ok, socket}, got: #{inspect(other)} + """ + end + + if socket.redirected do + raise "cannot redirect socket on update. Redirect before `update/2` is called" <> + " or use `send/2` and redirect in the `handle_info/2` response" end - if socket.redirected do - raise "cannot redirect socket on update. Redirect before `update/2` is called" <> - " or use `send/2` and redirect in the `handle_info/2` response" - end + socket - socket - else - Enum.reduce(assigns, socket, fn {k, v}, acc -> assign(acc, k, v) end) + true -> + Enum.reduce(assigns, socket, fn {k, v}, acc -> assign(acc, k, v) end) end end + def update_many!(sockets, component, list_of_assigns) + when is_list(list_of_assigns) and is_list(sockets) do + updated_sockets = component.update_many(list_of_assigns, sockets) + got_count = length(updated_sockets) + + if got_count != length(sockets) do + raise ArgumentError, + "expected #{inspect(component)}.update_many/2 to return the same number of " <> + "sockets as the number of given sockets but got: #{inspect(got_count)}" + end + + updated_sockets + end + @doc """ Signs the socket's flash into a token if it has been set. """ diff --git a/test/phoenix_component/warnings_test.exs b/test/phoenix_component/warnings_test.exs new file mode 100644 index 0000000000..b0429c15d3 --- /dev/null +++ b/test/phoenix_component/warnings_test.exs @@ -0,0 +1,26 @@ +defmodule Phoenix.WarningsTest do + use ExUnit.Case, async: true + + @moduletag :after_verify + import ExUnit.CaptureIO + + test "deprecated preload/1" do + warnings = + capture_io(:stderr, fn -> + defmodule DeprecatedPreloadComponent do + use Phoenix.LiveComponent + + def preload(list_of_assigns) do + list_of_assigns + end + + def render(assigns) do + ~H"
" + end + end + end) + + assert warnings =~ + "LiveComponent.preload/1 is deprecated (defind in Phoenix.WarningsTest.DeprecatedPreloadComponent). Use LiveComponent.update_many/2 instead" + end +end diff --git a/test/phoenix_live_view/diff_test.exs b/test/phoenix_live_view/diff_test.exs index cb6063c402..f491b4e3ec 100644 --- a/test/phoenix_live_view/diff_test.exs +++ b/test/phoenix_live_view/diff_test.exs @@ -507,6 +507,63 @@ defmodule Phoenix.LiveView.DiffTest do end end + defmodule TreeComponentUpdateMany do + use Phoenix.LiveComponent + + def update_many(list_of_assigns, sockets) do + send(self(), {:update_many, {list_of_assigns, sockets}}) + + Enum.zip_with(list_of_assigns, sockets, fn assigns, socket -> + socket |> assign(assigns) |> assign(:update_many_ran?, true) + end) + end + + def render(assigns) do + ~H""" +
+ <%= @id %> - <%= @update_many_ran? %> + <%= for {component, index} <- Enum.with_index(@children, 0) do %> + <%= index %>: <%= component %> + <% end %> +
+ """ + end + end + + defmodule TreeComponentBoth do + use Phoenix.LiveComponent + + # update_many should take precedence + def update_many(list_of_assigns, sockets) do + send(self(), {:update_many, {list_of_assigns, sockets}}) + + Enum.zip_with(list_of_assigns, sockets, fn assigns, socket -> + socket |> assign(assigns) |> assign(:update_many_ran?, true) + end) + end + + def preload(list_of_assigns) do + send(self(), {:preload, list_of_assigns}) + Enum.map(list_of_assigns, &Map.put(&1, :preloaded?, true)) + end + + def update(assigns, socket) do + send(self(), {:update, assigns}) + {:ok, assign(socket, assigns)} + end + + def render(assigns) do + ~H""" +
+ <%= @id %> - <%= @update_many_ran? %> + <%= for {component, index} <- Enum.with_index(@children, 0) do %> + <%= index %>: <%= component %> + <% end %> +
+ """ + end + end + defmodule NestedDynamicComponent do use Phoenix.LiveComponent @@ -1121,6 +1178,160 @@ defmodule Phoenix.LiveView.DiffTest do end end + test "on update_many" do + alias Component, as: C + alias TreeComponentUpdateMany, as: TC + + tree = %C{ + component: TC, + id: "R", + assigns: %{ + id: "R", + children: [ + %C{ + component: TC, + id: "A", + assigns: %{ + id: "A", + children: [ + %C{component: TC, id: "B", assigns: %{id: "B", children: []}}, + %C{component: TC, id: "C", assigns: %{id: "C", children: []}}, + %C{component: TC, id: "D", assigns: %{id: "D", children: []}} + ] + } + }, + %C{ + component: TC, + id: "X", + assigns: %{ + id: "X", + children: [ + %C{component: TC, id: "Y", assigns: %{id: "Y", children: []}}, + %C{component: TC, id: "Z", assigns: %{id: "Z", children: []}} + ] + } + } + ] + } + } + + rendered = component_template(%{component: tree}) + {socket, full_render, components} = render(rendered) + + assert %{ + c: %{ + 1 => %{0 => "R"}, + 2 => %{0 => "A"}, + 3 => %{0 => "X"}, + 4 => %{0 => "B"}, + 5 => %{0 => "C"}, + 6 => %{0 => "D"}, + 7 => %{0 => "Y"}, + 8 => %{0 => "Z"} + } + } = full_render + + assert socket.fingerprints == {rendered.fingerprint, %{}} + assert {_, _, 9} = components + + assert_received {:update_many, {[%{id: "R"}], [socket0]}} + assert %Socket{assigns: %{myself: %CID{cid: 1}}} = socket0 + + assert_received {:update_many, {[%{id: "A"}, %{id: "X"}], [socket0, socket1]}} + assert %Socket{assigns: %{myself: %CID{cid: 2}}} = socket0 + assert %Socket{assigns: %{myself: %CID{cid: 3}}} = socket1 + + assert_received {:update_many, + {[%{id: "B"}, %{id: "C"}, %{id: "D"}, %{id: "Y"}, %{id: "Z"}], + [socket0, socket1, socket2, socket3, socket4]}} + + assert %Socket{assigns: %{myself: %CID{cid: 4}}} = socket0 + assert %Socket{assigns: %{myself: %CID{cid: 5}}} = socket1 + assert %Socket{assigns: %{myself: %CID{cid: 6}}} = socket2 + assert %Socket{assigns: %{myself: %CID{cid: 7}}} = socket3 + assert %Socket{assigns: %{myself: %CID{cid: 8}}} = socket4 + + refute_received {:preload, _} + refute_received {:update, _} + end + + test "on update_many with preload and update callbacks" do + alias Component, as: C + alias TreeComponentBoth, as: TC + + tree = %C{ + component: TC, + id: "R", + assigns: %{ + id: "R", + children: [ + %C{ + component: TC, + id: "A", + assigns: %{ + id: "A", + children: [ + %C{component: TC, id: "B", assigns: %{id: "B", children: []}}, + %C{component: TC, id: "C", assigns: %{id: "C", children: []}}, + %C{component: TC, id: "D", assigns: %{id: "D", children: []}} + ] + } + }, + %C{ + component: TC, + id: "X", + assigns: %{ + id: "X", + children: [ + %C{component: TC, id: "Y", assigns: %{id: "Y", children: []}}, + %C{component: TC, id: "Z", assigns: %{id: "Z", children: []}} + ] + } + } + ] + } + } + + rendered = component_template(%{component: tree}) + {socket, full_render, components} = render(rendered) + + assert %{ + c: %{ + 1 => %{0 => "R"}, + 2 => %{0 => "A"}, + 3 => %{0 => "X"}, + 4 => %{0 => "B"}, + 5 => %{0 => "C"}, + 6 => %{0 => "D"}, + 7 => %{0 => "Y"}, + 8 => %{0 => "Z"} + } + } = full_render + + assert socket.fingerprints == {rendered.fingerprint, %{}} + assert {_, _, 9} = components + + assert_received {:update_many, {[%{id: "R"}], [socket0]}} + assert %Socket{assigns: %{myself: %CID{cid: 1}}} = socket0 + + assert_received {:update_many, {[%{id: "A"}, %{id: "X"}], [socket0, socket1]}} + assert %Socket{assigns: %{myself: %CID{cid: 2}}} = socket0 + assert %Socket{assigns: %{myself: %CID{cid: 3}}} = socket1 + + assert_received {:update_many, + {[%{id: "B"}, %{id: "C"}, %{id: "D"}, %{id: "Y"}, %{id: "Z"}], + [socket0, socket1, socket2, socket3, socket4]}} + + assert %Socket{assigns: %{myself: %CID{cid: 4}}} = socket0 + assert %Socket{assigns: %{myself: %CID{cid: 5}}} = socket1 + assert %Socket{assigns: %{myself: %CID{cid: 6}}} = socket2 + assert %Socket{assigns: %{myself: %CID{cid: 7}}} = socket3 + assert %Socket{assigns: %{myself: %CID{cid: 8}}} = socket4 + + refute_received {:preload, _} + refute_received {:update, _} + end + test "on addition" do component = %Component{id: "hello", assigns: %{from: :component}, component: MyComponent} rendered = component_template(%{component: component})