CacheMeIfYouCan.LiveViewCache (CacheMeIfYouCan v0.1.0)
View SourceThis 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.LiveViewCacheBad:
use CacheMeIfYouCan.LiveViewCache
use Phoenix.LiveViewQuickstart 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
@type reactive_callback() :: (reactive_socket() -> reactive_socket())
Functions
@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
@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
endand
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)
endThis 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
@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