Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions assets/js/phoenix_live_view/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const FOCUSABLE_INPUTS = [
export const CHECKABLE_INPUTS = ["checkbox", "radio"];
export const PHX_HAS_SUBMITTED = "phx-has-submitted";
export const PHX_SESSION = "data-phx-session";
export const PHX_ADOPT = "data-phx-adopt";
export const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`;
export const PHX_STICKY = "data-phx-sticky";
export const PHX_STATIC = "data-phx-static";
Expand Down
48 changes: 40 additions & 8 deletions assets/js/phoenix_live_view/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
PHX_PORTAL,
PHX_TELEPORTED_REF,
PHX_TELEPORTED_SRC,
PHX_ADOPT,
} from "./constants";

import {
Expand Down Expand Up @@ -97,6 +98,7 @@ export default class View {
private channel: Channel;
private rendered: Rendered | null;
private flash: string | null;
private liveReferer: string | null;
private parent: View | null;
private ref: number;
private lastAckRef: number | null;
Expand Down Expand Up @@ -130,6 +132,7 @@ export default class View {
this.isDead = false;
this.liveSocket = liveSocket;
this.flash = flash;
this.liveReferer = liveReferer;
this.parent = parentView;
this.root = parentView ? parentView.root : this;
this.el = el;
Expand Down Expand Up @@ -190,19 +193,23 @@ export default class View {
this.children = this.parent ? null : {};
this.root.children![this.id] = {};
this.formsForRecovery = {};
this.channel = this.liveSocket.channel(`lv:${this.id}`, () => {
this.channel = this.buildChannel();
this.portalElementIds = new Set();
}

buildChannel() {
return this.liveSocket.channel(`lv:${this.id}`, () => {
const url = this.href && this.expandURL(this.href);
return {
redirect: this.redirect ? url : undefined,
url: this.redirect ? undefined : url || undefined,
params: this.connectParams(liveReferer),
params: this.connectParams(this.liveReferer),
session: this.getSession(),
static: this.getStatic(),
flash: this.flash ?? undefined,
sticky: this.el.hasAttribute(PHX_STICKY),
};
});
this.portalElementIds = new Set();
}

setHref(href) {
Expand Down Expand Up @@ -251,6 +258,14 @@ export default class View {
return val === "" ? null : val;
}

getAdopt(): string | null {
return this.el.getAttribute(PHX_ADOPT);
}

clearAdopt() {
this.el.removeAttribute(PHX_ADOPT);
}

destroy(callback = function () {}) {
this.destroyAllChildren();
this.destroyPortalElements();
Expand Down Expand Up @@ -1150,11 +1165,28 @@ export default class View {
callback ? callback(this.joinCount, onDone) : onDone();
};

this.wrapPush(() => this.channel.join(), {
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
error: (error) => this.onJoinError(error),
timeout: () => this.onJoinError({ reason: "timeout" }),
});
const adoptToken = this.getAdopt();
this.clearAdopt();
if (adoptToken && "adopt" in this.channel) {
// @ts-expect-error adopt is not in the type definitions yet
this.wrapPush(() => this.channel.adopt(adoptToken), {
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
error: ({ reason }) => {
if (reason === "invalid adoption") {
this.channel.leave();
this.channel = this.buildChannel();
this.join();
}
},
Comment on lines +1174 to +1180

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We could also retry on the server, so phx_adopt always works.

timeout: () => this.onJoinError({ reason: "timeout" }),
});
} else {
this.wrapPush(() => this.channel.join(), {
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
error: (error) => this.onJoinError(error),
timeout: () => this.onJoinError({ reason: "timeout" }),
});
}
}

onJoinError(resp) {
Expand Down
10 changes: 9 additions & 1 deletion lib/phoenix_live_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ defmodule Phoenix.LiveView do
* `:on_mount` - a list of tuples with module names and arguments to be invoked
as `on_mount` hooks

* `:adoptable` - a boolean indicating whether the LiveView is adoptable.
If true, the LiveView can be adopted after the initial mount.

* `:adoption_timeout` - the timeout in milliseconds for the adoption.
If no socket connects within this time, the LiveView process is terminated. Defaults to 5000ms.

"""
def __live__(opts \\ []) do
on_mount = opts[:on_mount] || []
Expand All @@ -508,7 +514,9 @@ defmodule Phoenix.LiveView do
kind: :view,
layout: layout,
lifecycle: Phoenix.LiveView.Lifecycle.build(on_mount),
log: log
log: log,
adoptable: Keyword.get(opts, :adoptable, false),
adoption_timeout: Keyword.get(opts, :adoption_timeout, 5000)
}
end

Expand Down
9 changes: 8 additions & 1 deletion lib/phoenix_live_view/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ defmodule Phoenix.LiveView.Application do
@impl true
def start(_type, _args) do
Phoenix.LiveView.Logger.install()
Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor)

Supervisor.start_link(
[
{DynamicSupervisor, name: Phoenix.LiveView.AdoptionSupervisor, strategy: :one_for_one}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The other possibility would be to add something like Phoenix.Channel.spawn(...) which would spawn the new process under the Phoenix PoolSupervisor.

],
strategy: :one_for_one,
name: Phoenix.LiveView.Supervisor
)
end
end
136 changes: 113 additions & 23 deletions lib/phoenix_live_view/channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,27 @@ defmodule Phoenix.LiveView.Channel do
end

@impl true
def init({:adoptable_mount, init, timeout}) do
# adoptable render happens in handle_call
{:ok, {:adoptable, init, timeout}}
end

def init({pid, _ref}) do
{:ok, Process.monitor(pid)}
end

@impl true
def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, {:adoptable, state}) do
IO.puts("adopting existing channel!")
mount(auth_payload, from, phx_socket, state)
rescue
# Normalize exceptions for better client debugging
e -> reraise(e, __STACKTRACE__)
end

def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, ref) do
Process.demonitor(ref)
mount(auth_payload, from, phx_socket)
mount(auth_payload, from, phx_socket, nil)
rescue
# Normalize exceptions for better client debugging
e -> reraise(e, __STACKTRACE__)
Expand Down Expand Up @@ -359,6 +372,21 @@ defmodule Phoenix.LiveView.Channel do
handle_changed(state, new_socket, nil)
end

def handle_info({@prefix, :adoption_timeout}, {:adoptable, state}) do
# State is still {:adoptable, _}, so we were not adopted.
# Bye!
IO.puts("shutting down adoptable socket due to timeout")
{:stop, :shutdown, {:adoptable, state}}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We might need to define a special shutdown reason to signal either Phoenix or the JS client that the adoption failed. Otherwise I think it could happen that we try to adopt, but the timeout wins. Then we would send a "join crashed" to the client and LiveView would do a full page refresh instead of a normal join.

end

def handle_info({@prefix, :adoption_timeout}, state) do
{:noreply, state}
end

def handle_info({@prefix, :connected_diff, diff}, state) do
{:noreply, push_diff(state, diff, nil)}
end

def handle_info(msg, %{socket: socket} = state) do
msg
|> view_handle_info(socket)
Expand Down Expand Up @@ -431,6 +459,31 @@ defmodule Phoenix.LiveView.Channel do
{:reply, {:ok, component_info}, state}
end

# Phoenix.LiveView.Static.render/3
def handle_call({@prefix, :disconnected_adoptable_render}, _from, {:adoptable, init, timeout})
when is_function(init, 0) do
case init.() do
{:ok, socket} ->
state = %{
socket: socket,
components: Diff.new_components(),
fingerprints: Diff.new_fingerprints(),
initial_diff: nil
}

{:diff, diff, state} = render_diff(state, socket, true)
state = %{state | initial_diff: diff}

Process.send_after(self(), {@prefix, :adoption_timeout}, timeout)

# TODO: check if we can avoid sending the assigns back
{:reply, {:ok, Diff.to_iodata(diff), socket.assigns}, {:adoptable, state}}

{:stop, socket} ->
{:stop, :shutdown, {:stop, socket}, nil}
end
end

def handle_call(msg, from, %{socket: socket} = state) do
case socket.view.handle_call(msg, from, socket) do
{:reply, reply, %Socket{} = new_socket} ->
Expand Down Expand Up @@ -1035,11 +1088,26 @@ defmodule Phoenix.LiveView.Channel do
{socket, %{}, state.fingerprints, state.components}
end

diff = Diff.render_private(socket, diff)
diff =
case state do
%{initial_diff: initial_diff} when is_map(initial_diff) ->
send(self(), {@prefix, :connected_diff, Diff.render_private(socket, diff)})
initial_diff

_ ->
Diff.render_private(socket, diff)
end

new_socket = Utils.clear_temp(socket)

{:diff, diff,
%{state | socket: new_socket, fingerprints: fingerprints, components: components}}
%{
state
| socket: new_socket,
fingerprints: fingerprints,
components: components,
initial_diff: nil
}}
end

defp reply(state, {ref, extra}, status, payload) do
Expand All @@ -1066,7 +1134,7 @@ defmodule Phoenix.LiveView.Channel do

## Mount

defp mount(%{"session" => session_token} = params, from, phx_socket) do
defp mount(%{"session" => session_token} = params, from, phx_socket, adopted_state) do
%Phoenix.Socket{endpoint: endpoint, topic: topic} = phx_socket

case Session.verify_session(endpoint, topic, session_token, params["static"]) do
Expand Down Expand Up @@ -1126,7 +1194,8 @@ defmodule Phoenix.LiveView.Channel do
params,
from,
phx_socket,
connect_info
connect_info,
adopted_state
)
else
{:error, :unauthorized} ->
Expand All @@ -1145,7 +1214,7 @@ defmodule Phoenix.LiveView.Channel do
end
end

defp mount(%{}, from, phx_socket) do
defp mount(%{}, from, phx_socket, _adopted_state) do
Logger.error("Mounting #{phx_socket.topic} failed because no session was provided")
GenServer.reply(from, {:error, %{reason: "stale"}})
{:stop, :shutdown, :no_session}
Expand All @@ -1171,7 +1240,8 @@ defmodule Phoenix.LiveView.Channel do
params,
from,
phx_socket,
connect_info
connect_info,
adopted_state
) do
%Session{
id: id,
Expand Down Expand Up @@ -1206,16 +1276,24 @@ defmodule Phoenix.LiveView.Channel do
Process.monitor(transport_pid)
load_csrf_token(endpoint, socket_session)

socket = %Socket{
endpoint: endpoint,
view: view,
transport_pid: transport_pid,
parent_pid: parent,
root_pid: root_pid || self(),
id: id,
router: router,
sticky?: params["sticky"]
}
socket =
%Socket{
endpoint: endpoint,
view: view,
transport_pid: transport_pid,
parent_pid: parent,
root_pid: root_pid || self(),
id: id,
router: router,
sticky?: params["sticky"]
}
# For adoption, we only need to copy assigns and private.
# The state already contains existing fingerprints and
# components, but we need to do a full render anyway, since
# the client needs the full diff and the connected mount may
# have changed assigns. Unchanged expressions won't be executed.
|> maybe_copy_adopted(adopted_state, :private)
|> maybe_copy_adopted(adopted_state, :assigns)

{params, host_uri, action} =
case route do
Expand All @@ -1237,7 +1315,7 @@ defmodule Phoenix.LiveView.Channel do
socket
|> load_layout(route)
|> Utils.maybe_call_live_view_mount!(view, params, merged_session, url)
|> build_state(phx_socket)
|> build_state(phx_socket, adopted_state)
|> maybe_call_mount_handle_params(router, url, params)
|> reply_mount(from, verified, route)
|> maybe_subscribe_to_live_reload()
Expand Down Expand Up @@ -1276,6 +1354,12 @@ defmodule Phoenix.LiveView.Channel do
end
end

defp maybe_copy_adopted(socket, nil, _), do: socket

defp maybe_copy_adopted(socket, adopted_state, key) do
Map.put(socket, key, Map.fetch!(adopted_state.socket, key))
end

defp verify_flash(endpoint, %Session{} = verified, flash_token, connect_params) do
cond do
# flash_token is given by the client on live_redirects and has higher priority.
Expand Down Expand Up @@ -1446,18 +1530,24 @@ defmodule Phoenix.LiveView.Channel do
end
end

defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket) do
%{
defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket, adopted_state) do
base =
adopted_state ||
%{
components: Diff.new_components(),
fingerprints: Diff.new_fingerprints(),
initial_diff: nil
}

Map.merge(base, %{
join_ref: phx_socket.join_ref,
serializer: phx_socket.serializer,
socket: lv_socket,
topic: phx_socket.topic,
components: Diff.new_components(),
fingerprints: Diff.new_fingerprints(),
redirect_count: 0,
upload_names: %{},
upload_pids: %{}
}
})
end

defp build_uri(%{socket: socket}, "/" <> _ = to) do
Expand Down
Loading
Loading