Skip to content

Commit

Permalink
Docs
Browse files Browse the repository at this point in the history
  • Loading branch information
chrismccord committed Aug 16, 2023
1 parent d2a89e0 commit 914de49
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 25 deletions.
12 changes: 0 additions & 12 deletions lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2879,17 +2879,13 @@ defmodule Phoenix.Component do
<:loading>Loading organization...</:loading>
<:empty>You don't have an organization yet</:error>
<:error :let={{_kind, _reason}}>there was an error loading the organization</:error>
<:canceled :let={_reason}>loading canceled</:canceled>
<%= org.name %>
<.async_result>
```
"""
attr.(:assign, :any, required: true)
slot.(:loading, doc: "rendered while the assign is loading")

# TODO decide if we want an canceled slot
slot.(:canceled, dock: "rendered when the assign is canceled")

# TODO decide if we want an empty slot
slot.(:empty,
doc:
Expand All @@ -2913,14 +2909,6 @@ defmodule Phoenix.Component do
%AsyncResult{state: :loading} ->
~H|<%= render_slot(@loading) %>|

%AsyncResult{state: {:error, {:canceled, reason}}} ->
if assigns.canceled != [] do
assigns = Phoenix.Component.assign(assigns, reason: reason)
~H|<%= render_slot(@canceled, @reason) %>|
else
~H|<%= render_slot(@failed, @assign.state) %>|
end

%AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] ->
~H|<%= render_slot(@failed, @assign.state) %>|
end
Expand Down
90 changes: 90 additions & 0 deletions lib/phoenix_live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,96 @@ defmodule Phoenix.LiveView do
* [DOM patching and temporary assigns](dom-patching.md)
* [JavaScript interoperability](js-interop.md)
* [Uploads (External)](uploads-external.md)
## Async Operations
Performing asynchronous work is common in LiveViews and LiveComponents.
It allows the user to get a working UI quicky while the system fetches some
data in the background or talks to an external service. For async work,
you also typically need to handle the different states of the async operation,
such as loading, error, and the successful result. You also want to catch any
errors or exits and translate it to a meaningful update in the UI rather than
crashing the user experience.
### Async assigns
The `assign_async/3` function takes a name, a list of keys which will be assigned
asynchronously, and a function that returns the result of the async operation.
For example, let's say we want to async fetch a user's organization from the database,
as well as their profile and rank:
def mount(%{"slug" => slug}, _, socket) do
{:ok,
socket
|> assign(:foo, "bar")
|> assign_async(:org, fn -> {:ok, %{org: fetch_org!(slug)} end)
|> assign_async([:profile, :rank], fn -> {:ok, %{profile: ..., rank: ...}} end)}
end
Here we are assigning `:org` and `[:profile, :rank]` asynchronously. The async function
must return a `{:ok, assigns}` or `{:error, reason}` tuple, where `assigns` is a map of
the keys passed to `assign_async`. If the function returns other keys or a different
set of keys, an error is raised.
The state of the async operation is stored in the socket assigns within an
`%AsyncResult{}`. It carries the loading and error states, as well as the result.
For example, if we wanted to show the loading states in the UI for the `:org`,
our template could conditionally render the states:
```heex
<div :if={@org.state == :loading}>Loading organization...</div>
<div :if={org = @org.ok? && @org.result}}><%= org.name %> loaded!</div>
```
The `Phoenix.Component.async_result/1` function component can also be used to
declaratively render the different states using slots:
```heex
<.async_result :let={org} assign={@org}>
<:loading>Loading organization...</:loading>
<:empty>You don't have an organization yet</:error>
<:error :let={{_kind, _reason}}>there was an error loading the organization</:error>
<%= org.name %>
<.async_result>
```
Additionally, for async assigns which result in a list of items, you
can consume the assign directly. It will only enumerate
the results once the results are loaded. For example:
```heex
<div :for={orgs <- @orgs}><%= org.name %></div>
```
### Arbitrary async operations
Sometimes you need lower level control of asynchronous operations, while
still receiving process isolation and error handling. For this, you can use
`start_async/3` and the `AsyncResult` module directly:
def mount(%{"id" => id}, _, socket) do
{:ok,
socket
|> assign(:org, AsyncResult.new(:org))
|> start_async(:my_task, fn -> fetch_org!(id) end)
end
def handle_async(:org, {:ok, fetched_org}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end
def handle_async(:org, {:exit, reason}, socket) do
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.exit(org, reason))}
end
`start_async/3` is used to fetch the organization asynchronously. The
`handle_async/3` callback is called when the task completes or exists,
with the results wrapped in either `{:ok, result}` or `{:exit, reason}`.
The `AsyncResult` module is used to direclty to update the state of the
async operation, but you can also assign any value directly to the socket
if you want to handle the state yourself.
'''

alias Phoenix.LiveView.{Socket, LiveStream, Async}
Expand Down
2 changes: 1 addition & 1 deletion lib/phoenix_live_view/async.ex
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ defmodule Phoenix.LiveView.Async do
def cancel_async(%Socket{} = socket, %AsyncResult{} = result, reason) do
result.keys
|> Enum.reduce(socket, fn key, acc ->
Phoenix.Component.assign(acc, key, AsyncResult.canceled(result, reason))
Phoenix.Component.assign(acc, key, AsyncResult.error(result, reason))
end)
|> cancel_async(result.keys, reason)
end
Expand Down
7 changes: 0 additions & 7 deletions lib/phoenix_live_view/async_result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,6 @@ defmodule Phoenix.LiveView.AsyncResult do
%AsyncResult{result | state: :loading}
end

@doc """
Updates the state of the result to `{:error, {:canceled, reason}}`.
"""
def canceled(%AsyncResult{} = result, reason) do
error(result, {:canceled, reason})
end

@doc """
Updates the state of the result to `{:error, reason}`.
"""
Expand Down
4 changes: 2 additions & 2 deletions test/phoenix_live_view/integrations/assign_async_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do

assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}

assert render(lv) =~ "error: {:canceled, nil}"
assert render(lv) =~ "error: :cancel"

send(lv.pid, :renew_canceled)

Expand Down Expand Up @@ -155,7 +155,7 @@ defmodule Phoenix.LiveView.AssignAsyncTest do

assert_receive {:DOWN, ^async_ref, :process, _pid, :killed}

assert render(lv) =~ "lc_data canceled"
assert render(lv) =~ "error: :cancel"

Phoenix.LiveView.send_update(lv.pid, Phoenix.LiveViewTest.AsyncLive.LC,
id: "lc",
Expand Down
5 changes: 2 additions & 3 deletions test/support/live_views/general.ex
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive do
def handle_info(:boom, _socket), do: exit(:boom)

def handle_info(:cancel, socket) do
{:noreply, cancel_async(socket, socket.assigns.data)}
{:noreply, cancel_async(socket, socket.assigns.data, :cancel)}
end

def handle_info({:EXIT, pid, reason}, socket) do
Expand Down Expand Up @@ -450,7 +450,6 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do
<% end %>
<.async_result :let={data} assign={@lc_data}>
<:loading>lc_data loading...</:loading>
<:canceled>lc_data canceled</:canceled>
<:empty :let={_res}>no lc_data found</:empty>
<:failed :let={{kind, reason}}><%= kind %>: <%= inspect(reason) %></:failed>
Expand Down Expand Up @@ -510,7 +509,7 @@ defmodule Phoenix.LiveViewTest.AsyncLive.LC do
def update(%{action: :boom}, _socket), do: exit(:boom)

def update(%{action: :cancel}, socket) do
{:ok, cancel_async(socket, socket.assigns.lc_data)}
{:ok, cancel_async(socket, socket.assigns.lc_data, :cancel)}
end

def update(%{action: :renew_canceled}, socket) do
Expand Down

0 comments on commit 914de49

Please sign in to comment.