CacheMeIfYouCan.LiveViewCache (CacheMeIfYouCan v0.1.0)

View Source

This module provides reactive caching for a LiveView process.

To enable reactive properties, simply use CacheMeIfYouCan.LiveViewCache from a LiveView module.

Important

The LiveViewCache must be used AFTER the LiveView behavior or the on_mount/1 hook that sets up the reactive cache will not be registered.

Good:

use Phoenix.LiveView
use CacheMeIfYouCan.LiveViewCache

Bad:

use CacheMeIfYouCan.LiveViewCache
use Phoenix.LiveView

Quickstart Example

  defmodule MyApp.UsersLive do
    @moduledoc """
    A live list of users that can be filtered by role and status and sorted on username.
    Pagination, sorting, and filtering are all driven by the query params in the URL.
    The list of users and the total page count will be automatically recomputed when
    any of the constituent data points are updated via the query params.
    """

    use Phoenix.LiveView

    # We have to `use` the `LiveViewCache` in order to initialize the `@reactive_cache`s.
    # This must come after the `use Phoenix.LiveView` call or the on_mount hook will not be registered.
    use CacheMeIfYouCan.LiveViewCache

    # Configure the cached/computed properties and their deps at compile-time.
    @reactive_cache [
      key: :user_list,
      default_value: [],
      deps: [:sort_order, :page_no, :page_size, :filters],
      cb: &__MODULE__.refresh_user_list/1,
    ]
    @reactive_cache [
      key: :page_count,
      default_value: 1,
      deps: [:page_size, :filters],
      cb: &__MODULE__.refresh_page_count/1,
    ]

    # Callback to compute the :user_list cached assign.
    def refresh_user_list(socket) when is_reactive(socket) do
      %{filters: filters} = socket.assigns
      %{sort_order: sort_order} = socket.assigns
      %{page_no: page_no, page_size: page_size} = socket.assigns

      stream_async(socket, :user_list, fn ->
        res =
          from(User)
          |> filter_user_query(filters)
          |> order_by([user: u], {^sort_order, :username})
          |> limit(^page_size)
          |> offset((^page_no - 1) * ^page_size)
          |> Repo.all()

        {:ok, res, reset: true}
      end)
    end

    # Callback to compute the :page_count cached assign.
    def refresh_page_count(socket) when is_reactive(socket) do
      %{filters: filters, page_size: page_size} = socket.assigns

      start_async(socket, :fetch_page_count, fn ->
        from(User)
        |> filter_user_query(filters)
        |> Repo.aggregate(:count)
        |> (&(&1 / page_size)).()
        |> Float.ceil()
        |> trunc()
      end)
    end

    defp filter_user_query(query, filters) do
      Enum.reduce(filters, query, fn {column, value}, acc ->
        where(acc, [user: u], field(u, ^column == ^value))
      end)
    end

    @impl true
    def handle_async(:fetch_page_count, {:ok, 0 = _total_page_count}, socket) do
      updated_socket =
        socket
        |> assign_cached(:page_no, 1)
        |> assign(:page_count, 1)

      {:noreply, updated_socket}
    end

    @impl true
    def handle_async(:fetch_page_count, {:ok, total_page_count}, socket) do
      updated_socket =
        if socket.assigns.page_no > total_page_count do
          socket
          # We use `assign_cached/3` for the `:page_no` here so that it
          # will keep the `:user_list` up to date as well.
          |> assign_cached(:page_no, total_page_count)
          |> assign(:page_count, total_page_count)
        else
          socket
          |> assign(:page_count, total_page_count)
        end

      {:noreply, updated_socket}
    end

    @impl true
    def mount(_params, _session, socket) do
      mounted_socket =
        socket
        # Helper function to initialize the cached properties after assigning their deps.
        |> assign_new_cached(:filters, fn -> [] end)
        |> assign_new_cached(:sort_order, fn -> :asc end)
        |> assign_new_cached(:page_no, fn -> 1 end)
        |> assign_new_cached(:page_size, fn -> 25 end)

      {:ok, mounted_socket}
    end

    @impl true
    def handle_params(params, _uri, socket) do
      # Computed properties allow us to keep the `:user_list` updated with simple
      # declarative assigns from the GET params.
      updated_socket =
        socket
        |> parse_params(params, "sort", :sort_order)
        |> parse_params(params, "page", :page_no)
        |> parse_params(params, "per-page", :page_size)
        |> parse_filter_params(params)

      {:noreply, updated_socket}
    end

    defp parse_params(socket, params, param_key, assigns_key) when is_atom(assigns_key) do
      case Map.get(params, param_key, nil) do
        nil -> socket
        val -> assign_cached(socket, assigns_key, val)
      end
    end

    defp parse_filter_params(socket, %{"filter-by" => keys, "filter" => values}) do
      valid_columns = %{"userrole" => :role, "userstatus" => :status}

      new_filters =
        Enum.zip(keys, values)
        |> Enum.reduce([], fn {key, val} ->
          case Map.get(valid_columns, key, nil) do
            nil -> acc
            col_name -> [{col_name, val} | acc]
          end
        end)

      assign_cached(socket, :filters, new_filters)
    end

    defp parse_filter_params(socket, %{}), do: socket
  end

Summary

Functions

Wraps Phoenix.Component.assign/3 to automatically bust the cache for any assigns in the reactive_socket/0 that depend on the key being assigned.

Wraps Phoenix.Component.assign_new/3 to automatically bust the cache for any assigns in the reactive_socket/0 that depend on the key being assigned.

Forces recomputation of the specified reactive assign.

Guards

Guard to check that the socket has a reactive cache configured.

Types

reactive_callback()

@type reactive_callback() :: (reactive_socket() -> reactive_socket())

reactive_socket()

@type reactive_socket() :: %Phoenix.LiveView.Socket{
  assigns: %{__reactive_cache__: list()},
  endpoint: term(),
  host_uri: term(),
  id: term(),
  parent_pid: term(),
  private: term(),
  redirected: term(),
  root_pid: term(),
  router: term(),
  sticky?: term(),
  transport_pid: term(),
  view: term()
}

Functions

assign_cached(socket, key, value)

@spec assign_cached(socket :: reactive_socket(), key :: atom(), value :: term()) ::
  reactive_socket()

Wraps Phoenix.Component.assign/3 to automatically bust the cache for any assigns in the reactive_socket/0 that depend on the key being assigned.

Examples

  def handle_params(%{"page_no" => page_no}, _uri, socket) do
    assign_cached(socket, :page_no, page_no)
  end

assign_new_cached(socket, key, fun)

@spec assign_new_cached(
  socket :: reactive_socket(),
  key :: atom(),
  fun :: (-> term())
) ::
  reactive_socket()

Wraps Phoenix.Component.assign_new/3 to automatically bust the cache for any assigns in the reactive_socket/0 that depend on the key being assigned.

This function adds some QoL to Phoenix's assign_new function for use in the Phoenix.LiveView.mount/3 callback. It checks the socket is connected automatically and only computes the cached assigns on the connected render.

In other words, the following are roughly equivalent:

  def mount(_, _, socket) do
    if connected?(socket) do
      socket
      |> assign_new(:page_no, fn -> 1 end)
      |> assign_new(:page_size, fn -> 25 end)
      |> assign_new(:filters, fn -> [] end)
      |> assign_new(:users_list, fn -> [] end)
      |> assign_new(:page_count, fn -> 1 end)
      |> refresh_users_list()
      |> refresh_page_count()
    else
      socket
      |> assign_new(:page_no, fn -> 1 end)
      |> assign_new(:page_size, fn -> 25 end)
      |> assign_new(:filters, fn -> [] end)
      |> assign_new(:users_list, fn -> [] end)
      |> assign_new(:page_count, fn -> 1 end)
    end
  end

and

  def mount(_, _, socket) do
    socket
    |> assign_new_cached(:page_no, fn -> 1 end)
    |> assign_new_cached(:page_size, fn -> 25 end)
    |> assign_new_cached(:filters, fn -> [] end)
  end

This works best if the reactive_callback/0 functions configured in the reactive_socket/0 are async, which is generally always preferable.

Note on Streams

If you need to configure a stream for a computed property via Phoenix.LiveView.stream_configure/3, the stream_configure/3 call must come before any calls to assign_new_cached/3. This is because the assign_new_cached/3 helper will trigger the computation for the stream, which will cause the stream to initialize with the default configuration before the call to stream_configure/3 has modified the socket. This race condition will usually result in an "(Argument Error) cannot configure stream :assign_key after it has been streamed" error.

The solution is to either forego the use of assign_new_cached/3 and manually initialize defaults for all assigns, or simply call stream_configure/3 before any calls to assign_new_cached/3. As long as the stream is correctly configured before the computation is triggered, the initial computation for the stream assign will still work as expected.

Examples

  def mount(_params, _session, socket) do
    socket
    |> stream_configure(:user_list, dom_id: &("user-#{&1.user_id}"))
    |> assign_new_cached(:page_no, fn -> 1 end)
    |> assign_new_cached(:page_size, fn -> 25 end)
    |> assign_new_cached(:filters, fn -> [] end)
  end

invalidate_cache(socket, cache_key)

@spec invalidate_cache(socket :: reactive_socket(), cache_key :: atom()) ::
  reactive_socket()

Forces recomputation of the specified reactive assign.

This can be used to manually trigger a refresh of a particular assign from a module that doesn't hold a reference to the reactive_callback/0 configured to update it.

Examples

    def handle_info(:user_updated, {:ok, _user_data}, socket) do
      invalidate_cache(socket, :users_list)
    end

Guards

is_reactive(term)

(macro)

Guard to check that the socket has a reactive cache configured.