diff --git a/lib/phoenix_component.ex b/lib/phoenix_component.ex index 943b92e4cb..03ec372397 100644 --- a/lib/phoenix_component.ex +++ b/lib/phoenix_component.ex @@ -1313,6 +1313,50 @@ defmodule Phoenix.Component do raise_bad_socket_or_assign!("assign_new/3", assigns) end + @doc """ + Assigns the given `key` with value from `fun` into `socket` if one does not yet exist + or if any of the dependencies have changed. + + This function is similar to `assign_new/3` but adds support for dependencies. When any + of the dependencies listed in `deps` have changed (as tracked by the socket's `__changed__` + map), the function will be called to compute a new value for `key`. + + This is particularly useful for extracting common logic that depends on certain assigns + and needs to be recomputed only when those specific assigns change. It helps write + in a more declarative style by allowing the same function to be called in multiple + event handlers. + + ## Examples + + Imagine a LiveView that loads a user based on a user_id, and needs to recompute + user-related data whenever the user_id changes: + + # In your LiveView module + def mount(_params, _session, socket) do + {:ok, assign(socket, user_id: nil) |> load_user_data()} + end + + def handle_event("select_user", %{"id" => user_id}, socket) do + {:noreply, assign(socket, user_id: user_id) |> assign_dependencies()} + end + + # This shared function keeps all dependent data in sync + # It will only recompute values when their dependencies change + defp assign_dependencies(socket) do + socket + |> assign_new(:user, [:user_id], &load_user(&1.user_id)) + end + + In this example, `:user` will be recomputed whenever `:user_id` changes. + + The function can be either a zero-arity function or a one-arity function that receives + the current assigns. + """ + def assign_new(%Socket{} = socket, key, deps, fun) when is_list(deps) do + validate_assign_key!(key) + Phoenix.LiveView.Utils.assign_new(socket, key, deps, fun) + end + defp raise_bad_socket_or_assign!(name, assigns) do extra = case assigns do diff --git a/lib/phoenix_live_view/utils.ex b/lib/phoenix_live_view/utils.ex index 245a89428b..d68b5dd726 100644 --- a/lib/phoenix_live_view/utils.ex +++ b/lib/phoenix_live_view/utils.ex @@ -83,6 +83,74 @@ defmodule Phoenix.LiveView.Utils do end end + @doc """ + Assigns the given `key` with value from `fun` into `socket` if one does not yet exist + or if any of the dependencies have changed. + + This function is particularly useful for computing derived values that depend on other assigns. + When any of the dependencies listed in `deps` have changed (as tracked by the socket's `__changed__` + map), the function will be called to compute a new value for `key`. + + ## Example + + Imagine a LiveView that manages a user profile where we need to fetch user data when the user ID changes. + We can factor out the refresh logic into a common function: + + def mount(_params, _session, socket) do + {:ok, assign(socket, :user_id, nil) |> refresh_assigns()} + end + + def handle_event("select_user", %{"id" => user_id}, socket) do + {:noreply, socket |> assign(:user_id, user_id) |> refresh_assigns()} + end + + def handle_event("update_profile", params, socket) do + {:noreply, socket |> handle_profile_update(params) |> refresh_assigns()} + end + + def handle_params(%{"user_id" => user_id}, _uri, socket) do + {:noreply, socket |> assign(:user_id, user_id) |> refresh_assigns()} + end + + # Centralized refresh function that can be called from any event handler + defp refresh_assigns(socket) do + socket + |> assign_new(:user, [:user_id], &fetch_user_data/1) + end + + defp fetch_user_data(%{user_id: user_id}) when is_nil(user_id), do: nil + defp fetch_user_data(%{user_id: user_id}) do + # This expensive database query only runs when user_id changes + Repo.get(User, user_id) + end + + In this example, we've factored out the assign refresh logic into a common `refresh_assigns/1` + function that we can call from any event handler or callback. The `user` struct is only fetched + when `user_id` changes, even though we call `refresh_assigns/1` in multiple places. + + This approach keeps your code DRY and ensures expensive operations only run when necessary. + """ + def assign_new(%Socket{} = socket, key, deps, fun) + when is_list(deps) and + (is_function(fun, 0) or is_function(fun, 1)) do + # If any dependency has changed, always recompute the value + if deps_in_changed?(socket, deps) do + case fun do + fun when is_function(fun, 1) -> force_assign(socket, key, fun.(socket.assigns)) + fun when is_function(fun, 0) -> force_assign(socket, key, fun.()) + end + else + # Otherwise, use the standard assign_new behavior + assign_new(socket, key, fun) + end + end + + defp deps_in_changed?(%Socket{assigns: %{__changed__: changed}}, deps) when is_map(changed) do + Enum.any?(deps, &Map.has_key?(changed, &1)) + end + + defp deps_in_changed?(%Socket{}, _deps), do: false + @doc """ Forces an assign on a socket. """ diff --git a/test/phoenix_component_test.exs b/test/phoenix_component_test.exs index 964f20e1df..8efffdaf95 100644 --- a/test/phoenix_component_test.exs +++ b/test/phoenix_component_test.exs @@ -170,6 +170,124 @@ defmodule Phoenix.ComponentUnitTest do end end + describe "assign_new with dependencies" do + test "recomputes value when dependencies change" do + socket = + @socket + |> assign(user_id: 123) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 123" + + # Change a dependency + socket = + socket + |> Utils.clear_changed() + |> assign(user_id: 456) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 456" + assert changed?(socket, :user) + end + + test "doesn't recompute when dependencies don't change" do + socket = + @socket + |> assign(user_id: 123, counter: 0) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 123" + + # Change an unrelated assign + socket = + socket + |> Utils.clear_changed() + |> assign(counter: 1) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 123" + refute changed?(socket, :user) + end + + test "works with zero-arity functions" do + socket = + @socket + |> assign(user_id: 123) + |> assign_new(:random_value, [:user_id], fn -> :rand.uniform(100) end) + + assert is_integer(socket.assigns.random_value) + assert socket.assigns.random_value > 0 && socket.assigns.random_value <= 100 + + # Change a dependency + socket = + socket + |> Utils.clear_changed() + |> assign(user_id: 456) + |> assign_new(:random_value, [:user_id], fn -> :rand.uniform(100) end) + + # Value should be recomputed + assert is_integer(socket.assigns.random_value) + assert socket.assigns.random_value > 0 && socket.assigns.random_value <= 100 + # It's theoretically possible but extremely unlikely that we'd get the same random number + assert changed?(socket, :random_value) + end + + test "works with multiple dependencies" do + socket = + @socket + |> assign(first_name: "John", last_name: "Doe") + |> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} -> + "#{first} #{last}" + end) + + assert socket.assigns.full_name == "John Doe" + + # Change first dependency + socket = + socket + |> Utils.clear_changed() + |> assign(first_name: "Jane") + |> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} -> + "#{first} #{last}" + end) + + assert socket.assigns.full_name == "Jane Doe" + assert changed?(socket, :full_name) + + # Change second dependency + socket = + socket + |> Utils.clear_changed() + |> assign(last_name: "Smith") + |> assign_new(:full_name, [:first_name, :last_name], fn %{first_name: first, last_name: last} -> + "#{first} #{last}" + end) + + assert socket.assigns.full_name == "Jane Smith" + assert changed?(socket, :full_name) + end + + test "behaves like assign_new when key doesn't exist" do + socket = + @socket + |> assign(user_id: 123) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 123" + assert changed?(socket, :user) + end + + test "uses parent assigns when present" do + socket = + put_in(@socket.private[:assign_new], {%{user_id: 999}, []}) + |> assign(user_id: 123) + |> assign_new(:user, [:user_id], fn %{user_id: user_id} -> "User #{user_id}" end) + + assert socket.assigns.user == "User 123" + assert changed?(socket, :user) + end + end + describe "assign_new with assigns" do test "tracks changes" do assigns = assign_new(@assigns_changes, :key, fn -> raise "won't be invoked" end) diff --git a/test/phoenix_live_view/integrations/assigns_test.exs b/test/phoenix_live_view/integrations/assigns_test.exs index 9fd2debce0..553420c87c 100644 --- a/test/phoenix_live_view/integrations/assigns_test.exs +++ b/test/phoenix_live_view/integrations/assigns_test.exs @@ -50,6 +50,38 @@ defmodule Phoenix.LiveView.AssignsTest do end end + describe "assign_new with dependencies" do + test "recomputes value when dependencies change", %{conn: conn} do + {:ok, view, _html} = live(conn, "/deps") + + # Initial state + assert render(view) =~ "user_id: 123" + assert render(view) =~ "user_name: User 123" + + # Update user_id which should trigger recomputation of user_name + view |> element("button", "Change User ID") |> render_click() + + assert render(view) =~ "user_id: 456" + assert render(view) =~ "user_name: User 456" + end + + test "doesn't recompute when dependencies don't change", %{conn: conn} do + {:ok, view, _html} = live(conn, "/deps") + + # Initial state + assert render(view) =~ "counter: 0" + assert render(view) =~ "user_id: 123" + assert render(view) =~ "user_name: User 123" + + # Update counter which should not trigger recomputation of user_name + view |> element("button", "Increment Counter") |> render_click() + + assert render(view) =~ "counter: 1" + assert render(view) =~ "user_id: 123" + assert render(view) =~ "user_name: User 123" + end + end + describe "temporary assigns" do test "can be configured with mount options", %{conn: conn} do {:ok, conf_live, html} = diff --git a/test/support/live_views/deps_live.ex b/test/support/live_views/deps_live.ex new file mode 100644 index 0000000000..eceaf4fc04 --- /dev/null +++ b/test/support/live_views/deps_live.ex @@ -0,0 +1,44 @@ +defmodule Phoenix.LiveViewTest.Support.DepsLive do + use Phoenix.LiveView + + def render(assigns) do + ~H""" +
+
counter: <%= @counter %>
+
user_id: <%= @user_id %>
+
user_name: <%= @user_name %>
+ + +
+ """ + end + + def mount(_params, _session, socket) do + socket = + socket + |> assign(:counter, 0) + |> assign(:user_id, 123) + |> assign_new(:user_name, [:user_id], fn %{user_id: user_id} -> + # In a real app, this would be a database query + "User #{user_id}" + end) + + {:ok, socket} + end + + def handle_event("increment", _, socket) do + {:noreply, update(socket, :counter, &(&1 + 1))} + end + + def handle_event("change_user_id", _, socket) do + socket = + socket + |> assign(:user_id, 456) + |> assign_new(:user_name, [:user_id], fn %{user_id: user_id} -> + # In a real app, this would be a database query + "User #{user_id}" + end) + + {:noreply, socket} + end +end diff --git a/test/support/router.ex b/test/support/router.ex index 1192591421..d60c872a3a 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -36,6 +36,7 @@ defmodule Phoenix.LiveViewTest.Support.Router do live "/same-child", SameChildLive live "/root", RootLive live "/opts", OptsLive + live "/deps", DepsLive live "/shuffle", ShuffleLive live "/components", WithComponentLive live "/multi-targets", WithMultipleTargets