Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add assign_async and start_async #2763

Merged
merged 63 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
36ff163
WIP
chrismccord Aug 4, 2023
706b657
Don't need this
chrismccord Aug 4, 2023
fd1c4e7
render_async
chrismccord Aug 5, 2023
b6a30b9
Exit does not bring down lv
chrismccord Aug 5, 2023
b75f7d8
Test cancel_async
chrismccord Aug 6, 2023
2152fa3
Enum protocol
chrismccord Aug 6, 2023
f91f501
LiveComponent tests
chrismccord Aug 7, 2023
44c2ad3
Docs
chrismccord Aug 7, 2023
5226415
FC
chrismccord Aug 7, 2023
ab39c68
AsyncResult WIP
chrismccord Aug 11, 2023
eda3907
Checkpoint
chrismccord Aug 11, 2023
1d20557
Docs
chrismccord Aug 11, 2023
0067372
Docs
chrismccord Aug 11, 2023
4bc97e7
Docs
chrismccord Aug 11, 2023
1b0b795
Fixup
chrismccord Aug 11, 2023
353e834
typo
chrismccord Aug 12, 2023
cb35f0f
Docs
chrismccord Aug 12, 2023
707db47
Refactor
chrismccord Aug 16, 2023
c3be3a1
Move some things around
chrismccord Aug 16, 2023
28efff2
Update lib/phoenix_live_view/test/live_view_test.ex
chrismccord Aug 16, 2023
aaa37af
Avoid extra socket ops
chrismccord Aug 16, 2023
d2a89e0
Optimize
chrismccord Aug 16, 2023
914de49
Docs
chrismccord Aug 16, 2023
bd23bcb
Add start_async tests
chrismccord Aug 16, 2023
6896f0d
Formatting
chrismccord Aug 16, 2023
8e6a32d
optimize
chrismccord Aug 16, 2023
05e9e81
fixup
chrismccord Aug 16, 2023
5e5ad13
fixup
chrismccord Aug 16, 2023
a1bb446
Use reply_demonitor
chrismccord Aug 17, 2023
bbd1295
Split state and status
chrismccord Aug 17, 2023
6c8386f
Update lib/phoenix_live_view.ex
chrismccord Aug 17, 2023
412eb78
Set to loading if existing
chrismccord Aug 17, 2023
bfb7ba4
Update lib/phoenix_component.ex
chrismccord Aug 20, 2023
9c9446a
loading/failed
chrismccord Aug 21, 2023
d33f3de
typo
chrismccord Aug 21, 2023
fc3c1d6
Fixup
chrismccord Aug 21, 2023
3f34aee
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
67a2531
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
73e76c3
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
fb44b75
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
6b54a70
to_exit inside task
chrismccord Aug 21, 2023
9f227b5
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
cbf48cc
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
42ea838
Update lib/phoenix_live_view.ex
chrismccord Aug 21, 2023
9f3d776
Key
chrismccord Aug 21, 2023
572d457
public cancel_existing_async
chrismccord Aug 21, 2023
876888c
Docs
chrismccord Aug 21, 2023
0cfeda6
Docs
chrismccord Aug 21, 2023
ec83de3
Update lib/phoenix_live_view.ex
chrismccord Aug 22, 2023
8439355
No need for task
chrismccord Aug 22, 2023
f4da1ed
CI
chrismccord Aug 22, 2023
3a4191f
CI
chrismccord Aug 22, 2023
72e9f33
CI
chrismccord Aug 22, 2023
3f59a42
Bump
chrismccord Aug 23, 2023
9c6e97c
Bump docs
chrismccord Aug 23, 2023
eaf5a0a
Update lib/phoenix_live_view/async_result.ex
chrismccord Aug 23, 2023
e254592
Update lib/phoenix_live_view/async_result.ex
chrismccord Aug 23, 2023
aa23281
Update lib/phoenix_live_view/async_result.ex
chrismccord Aug 23, 2023
844e08a
Update lib/phoenix_live_view/async_result.ex
chrismccord Aug 23, 2023
9ae5081
Update lib/phoenix_live_view/async_result.ex
chrismccord Aug 23, 2023
c23041f
AsyncResult tests
chrismccord Aug 23, 2023
4f62548
Docs
chrismccord Aug 23, 2023
27755a7
Kill unnecessary clause
chrismccord Aug 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion lib/phoenix_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ defmodule Phoenix.Component do

## Functions

alias Phoenix.LiveView.{Static, Socket}
alias Phoenix.LiveView.{Static, Socket, AsyncResult}
@reserved_assigns Phoenix.Component.Declarative.__reserved__()
# Note we allow live_action as it may be passed down to a component, so it is not listed
@non_assignables [:uploads, :streams, :socket, :myself]
Expand Down Expand Up @@ -2868,4 +2868,49 @@ defmodule Phoenix.Component do
%><% end %>
"""
end

@doc """
Renders an async assign with slots for the different loading states.

## Examples

```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>
```
"""
attr.(:assign, :any, required: true)
slot.(:loading, doc: "rendered while the assign is loading")

# TODO decide if we want an empty slot
slot.(:empty,
doc:
"rendered when the result is loaded and is either nil or an empty list. Receives the result as a :let."
)

slot.(:failed,
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
doc:
"rendered when an error or exit is caught or assign_async returns `{:error, reason}`. Receives the error as a :let."
)

def async_result(assigns) do
case assigns.assign do
%AsyncResult{state: state, ok?: once_ok?, result: result} when state == :ok or once_ok? ->
if assigns.empty != [] && result in [nil, []] do
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
~H|<%= render_slot(@empty, @assign.result) %>|
else
~H|<%= render_slot(@inner_block, @assign.result) %>|
end

%AsyncResult{state: :loading} ->
~H|<%= render_slot(@loading) %>|

%AsyncResult{state: {kind, _reason}} when kind in [:error, :exit] ->
~H|<%= render_slot(@failed, @assign.state) %>|
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is so clean! 🧼

end
203 changes: 202 additions & 1 deletion lib/phoenix_live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,99 @@ 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,
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
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>
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
<: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
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
can consume the assign directly. It will only enumerate
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
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
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end

def handle_async(:org, {:exit, reason}, socket) do
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
%{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,
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
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}
alias Phoenix.LiveView.{Socket, LiveStream, Async}

@type unsigned_params :: map

Expand Down Expand Up @@ -666,6 +756,40 @@ defmodule Phoenix.LiveView do
"""
def connected?(%Socket{transport_pid: transport_pid}), do: transport_pid != nil

@doc """
Puts a new private key and value in the socket.

Privates are *not change tracked*. This storage is meant to be used by
users and libraries to hold state that doesn't require
change tracking. The keys should be prefixed with the app/library name.

## Examples

Key values can be placed in private:

put_private(socket, :myapp_meta, %{foo: "bar"})

And then retrieved:

socket.private[:myapp_meta]
"""
@reserved_privates ~w(
connect_params
connect_info
assign_new
live_layout
lifecycle
root_view
__temp__
)a
def put_private(%Socket{} = socket, key, value) when key not in @reserved_privates do
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
%Socket{socket | private: Map.put(socket.private, key, value)}
end

def put_private(%Socket{}, bad_key, _value) do
raise ArgumentError, "cannot set reserved private key #{inspect(bad_key)}"
end

@doc """
Adds a flash message to the socket to be displayed.

Expand All @@ -690,6 +814,7 @@ defmodule Phoenix.LiveView do
iex> put_flash(socket, :info, "It worked!")
iex> put_flash(socket, :error, "You can't access that page")
"""

defdelegate put_flash(socket, kind, msg), to: Phoenix.LiveView.Utils

@doc """
Expand Down Expand Up @@ -1842,4 +1967,80 @@ defmodule Phoenix.LiveView do
|> Map.update!(:__changed__, &MapSet.put(&1, name))
end)
end

@doc """
Assigns keys asynchronously.

The task is linked to the caller and errors are wrapped.
Each key passed to `assign_async/3` will be assigned to
an `%AsyncResult{}` struct holding the status of the operation
and the result when completed.

## Examples

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

See the moduledoc for more information.
"""
def assign_async(%Socket{} = socket, key_or_keys, func)
when (is_atom(key_or_keys) or is_list(key_or_keys)) and
is_function(func, 0) do
Async.assign_async(socket, key_or_keys, func)
end

@doc """
Starts an ansynchronous task and invokes callback to handle the result.

The task is linked to the caller and errors/exits are wrapped.
The result of the task is sent to the `handle_async/3` callback
of the caller LiveView or LiveComponent.

## Examples

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
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.ok(org, fetched_org))}
end

def handle_async(:org, {:exit, reason}, socket) do
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
%{org: org} = socket.assigns
{:noreply, assign(socket, :org, AsyncResult.exit(org, reason))}
end

See the moduledoc for more information.
"""
def start_async(%Socket{} = socket, name, func)
when is_atom(name) and is_function(func, 0) do
Async.start_async(socket, name, func)
end

@doc """
Cancels an async operation.

Accepts either the `%AsyncResult{}` when using `assign_async/3` or
the keys passed to `start_async/3`.

## Examples

cancel_async(socket, :preview)
cancel_async(socket, :preview, :my_reason)
cancel_async(socket, [:profile, :rank])
cancel_async(socket, socket.assigns.preview)
"""
def cancel_async(socket, async_or_keys, reason \\ nil) do
Async.cancel_async(socket, async_or_keys, reason)
end
end
Loading
Loading