Skip to content

Commit f13ff3a

Browse files
committed
proof of concept for channel adoption
1 parent 0735009 commit f13ff3a

9 files changed

Lines changed: 256 additions & 66 deletions

File tree

assets/js/phoenix_live_view/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const FOCUSABLE_INPUTS = [
6464
export const CHECKABLE_INPUTS = ["checkbox", "radio"];
6565
export const PHX_HAS_SUBMITTED = "phx-has-submitted";
6666
export const PHX_SESSION = "data-phx-session";
67+
export const PHX_ADOPT = "data-phx-adopt";
6768
export const PHX_VIEW_SELECTOR = `[${PHX_SESSION}]`;
6869
export const PHX_STICKY = "data-phx-sticky";
6970
export const PHX_STATIC = "data-phx-static";

assets/js/phoenix_live_view/view.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
PHX_PORTAL,
4545
PHX_TELEPORTED_REF,
4646
PHX_TELEPORTED_SRC,
47+
PHX_ADOPT,
4748
} from "./constants";
4849

4950
import {
@@ -97,6 +98,7 @@ export default class View {
9798
private channel: Channel;
9899
private rendered: Rendered | null;
99100
private flash: string | null;
101+
private liveReferer: string | null;
100102
private parent: View | null;
101103
private ref: number;
102104
private lastAckRef: number | null;
@@ -130,6 +132,7 @@ export default class View {
130132
this.isDead = false;
131133
this.liveSocket = liveSocket;
132134
this.flash = flash;
135+
this.liveReferer = liveReferer;
133136
this.parent = parentView;
134137
this.root = parentView ? parentView.root : this;
135138
this.el = el;
@@ -190,19 +193,23 @@ export default class View {
190193
this.children = this.parent ? null : {};
191194
this.root.children![this.id] = {};
192195
this.formsForRecovery = {};
193-
this.channel = this.liveSocket.channel(`lv:${this.id}`, () => {
196+
this.channel = this.buildChannel();
197+
this.portalElementIds = new Set();
198+
}
199+
200+
buildChannel() {
201+
return this.liveSocket.channel(`lv:${this.id}`, () => {
194202
const url = this.href && this.expandURL(this.href);
195203
return {
196204
redirect: this.redirect ? url : undefined,
197205
url: this.redirect ? undefined : url || undefined,
198-
params: this.connectParams(liveReferer),
206+
params: this.connectParams(this.liveReferer),
199207
session: this.getSession(),
200208
static: this.getStatic(),
201209
flash: this.flash ?? undefined,
202210
sticky: this.el.hasAttribute(PHX_STICKY),
203211
};
204212
});
205-
this.portalElementIds = new Set();
206213
}
207214

208215
setHref(href) {
@@ -251,6 +258,14 @@ export default class View {
251258
return val === "" ? null : val;
252259
}
253260

261+
getAdopt(): string | null {
262+
return this.el.getAttribute(PHX_ADOPT);
263+
}
264+
265+
clearAdopt() {
266+
this.el.removeAttribute(PHX_ADOPT);
267+
}
268+
254269
destroy(callback = function () {}) {
255270
this.destroyAllChildren();
256271
this.destroyPortalElements();
@@ -1150,11 +1165,28 @@ export default class View {
11501165
callback ? callback(this.joinCount, onDone) : onDone();
11511166
};
11521167

1153-
this.wrapPush(() => this.channel.join(), {
1154-
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
1155-
error: (error) => this.onJoinError(error),
1156-
timeout: () => this.onJoinError({ reason: "timeout" }),
1157-
});
1168+
const adoptToken = this.getAdopt();
1169+
this.clearAdopt();
1170+
if (adoptToken && "adopt" in this.channel) {
1171+
// @ts-expect-error adopt is not in the type definitions yet
1172+
this.wrapPush(() => this.channel.adopt(adoptToken), {
1173+
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
1174+
error: ({ reason }) => {
1175+
if (reason === "invalid adoption") {
1176+
this.channel.leave();
1177+
this.channel = this.buildChannel();
1178+
this.join();
1179+
}
1180+
},
1181+
timeout: () => this.onJoinError({ reason: "timeout" }),
1182+
});
1183+
} else {
1184+
this.wrapPush(() => this.channel.join(), {
1185+
ok: (resp) => this.liveSocket.requestDOMUpdate(() => this.onJoin(resp)),
1186+
error: (error) => this.onJoinError(error),
1187+
timeout: () => this.onJoinError({ reason: "timeout" }),
1188+
});
1189+
}
11581190
}
11591191

11601192
onJoinError(resp) {

lib/phoenix_live_view.ex

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,12 @@ defmodule Phoenix.LiveView do
486486
* `:on_mount` - a list of tuples with module names and arguments to be invoked
487487
as `on_mount` hooks
488488
489+
* `:adoptable` - a boolean indicating whether the LiveView is adoptable.
490+
If true, the LiveView can be adopted after the initial mount.
491+
492+
* `:adoption_timeout` - the timeout in milliseconds for the adoption.
493+
If no socket connects within this time, the LiveView process is terminated. Defaults to 5000ms.
494+
489495
"""
490496
def __live__(opts \\ []) do
491497
on_mount = opts[:on_mount] || []
@@ -508,7 +514,9 @@ defmodule Phoenix.LiveView do
508514
kind: :view,
509515
layout: layout,
510516
lifecycle: Phoenix.LiveView.Lifecycle.build(on_mount),
511-
log: log
517+
log: log,
518+
adoptable: Keyword.get(opts, :adoptable, false),
519+
adoption_timeout: Keyword.get(opts, :adoption_timeout, 5000)
512520
}
513521
end
514522

lib/phoenix_live_view/application.ex

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ defmodule Phoenix.LiveView.Application do
66
@impl true
77
def start(_type, _args) do
88
Phoenix.LiveView.Logger.install()
9-
Supervisor.start_link([], strategy: :one_for_one, name: Phoenix.LiveView.Supervisor)
9+
10+
Supervisor.start_link(
11+
[
12+
{DynamicSupervisor, name: Phoenix.LiveView.AdoptionSupervisor, strategy: :one_for_one}
13+
],
14+
strategy: :one_for_one,
15+
name: Phoenix.LiveView.Supervisor
16+
)
1017
end
1118
end

lib/phoenix_live_view/channel.ex

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,27 @@ defmodule Phoenix.LiveView.Channel do
7474
end
7575

7676
@impl true
77+
def init({:adoptable_mount, init, timeout}) do
78+
# adoptable render happens in handle_call
79+
{:ok, {:adoptable, init, timeout}}
80+
end
81+
7782
def init({pid, _ref}) do
7883
{:ok, Process.monitor(pid)}
7984
end
8085

8186
@impl true
87+
def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, {:adoptable, state}) do
88+
IO.puts("adopting existing channel!")
89+
mount(auth_payload, from, phx_socket, state)
90+
rescue
91+
# Normalize exceptions for better client debugging
92+
e -> reraise(e, __STACKTRACE__)
93+
end
94+
8295
def handle_info({Phoenix.Channel, auth_payload, from, phx_socket}, ref) do
8396
Process.demonitor(ref)
84-
mount(auth_payload, from, phx_socket)
97+
mount(auth_payload, from, phx_socket, nil)
8598
rescue
8699
# Normalize exceptions for better client debugging
87100
e -> reraise(e, __STACKTRACE__)
@@ -359,6 +372,21 @@ defmodule Phoenix.LiveView.Channel do
359372
handle_changed(state, new_socket, nil)
360373
end
361374

375+
def handle_info({@prefix, :adoption_timeout}, {:adoptable, state}) do
376+
# State is still {:adoptable, _}, so we were not adopted.
377+
# Bye!
378+
IO.puts("shutting down adoptable socket due to timeout")
379+
{:stop, :shutdown, {:adoptable, state}}
380+
end
381+
382+
def handle_info({@prefix, :adoption_timeout}, state) do
383+
{:noreply, state}
384+
end
385+
386+
def handle_info({@prefix, :connected_diff, diff}, state) do
387+
{:noreply, push_diff(state, diff, nil)}
388+
end
389+
362390
def handle_info(msg, %{socket: socket} = state) do
363391
msg
364392
|> view_handle_info(socket)
@@ -431,6 +459,30 @@ defmodule Phoenix.LiveView.Channel do
431459
{:reply, {:ok, component_info}, state}
432460
end
433461

462+
# Phoenix.LiveView.Static.render/3
463+
def handle_call({@prefix, :disconnected_adoptable_render}, _from, {:adoptable, init, timeout})
464+
when is_function(init, 0) do
465+
case init.() do
466+
{:ok, socket} ->
467+
state = %{
468+
socket: socket,
469+
components: Diff.new_components(),
470+
fingerprints: Diff.new_fingerprints(),
471+
initial_diff: nil
472+
}
473+
474+
{:diff, diff, state} = render_diff(state, socket, true)
475+
state = %{state | initial_diff: diff}
476+
477+
Process.send_after(self(), {@prefix, :adoption_timeout}, timeout)
478+
479+
{:reply, {:ok, Diff.to_iodata(diff)}, {:adoptable, state}}
480+
481+
{:stop, socket} ->
482+
{:stop, :shutdown, {:stop, socket}, nil}
483+
end
484+
end
485+
434486
def handle_call(msg, from, %{socket: socket} = state) do
435487
case socket.view.handle_call(msg, from, socket) do
436488
{:reply, reply, %Socket{} = new_socket} ->
@@ -1035,11 +1087,20 @@ defmodule Phoenix.LiveView.Channel do
10351087
{socket, %{}, state.fingerprints, state.components}
10361088
end
10371089

1038-
diff = Diff.render_private(socket, diff)
1090+
diff =
1091+
case state do
1092+
%{initial_diff: initial_diff} when is_map(initial_diff) ->
1093+
send(self(), {@prefix, :connected_diff, Diff.render_private(socket, diff)})
1094+
initial_diff
1095+
1096+
_ ->
1097+
Diff.render_private(socket, diff)
1098+
end
1099+
10391100
new_socket = Utils.clear_temp(socket)
10401101

10411102
{:diff, diff,
1042-
%{state | socket: new_socket, fingerprints: fingerprints, components: components}}
1103+
%{state | socket: new_socket, fingerprints: fingerprints, components: components, initial_diff: nil}}
10431104
end
10441105

10451106
defp reply(state, {ref, extra}, status, payload) do
@@ -1066,7 +1127,7 @@ defmodule Phoenix.LiveView.Channel do
10661127

10671128
## Mount
10681129

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

10721133
case Session.verify_session(endpoint, topic, session_token, params["static"]) do
@@ -1126,7 +1187,8 @@ defmodule Phoenix.LiveView.Channel do
11261187
params,
11271188
from,
11281189
phx_socket,
1129-
connect_info
1190+
connect_info,
1191+
adopted_state
11301192
)
11311193
else
11321194
{:error, :unauthorized} ->
@@ -1145,7 +1207,7 @@ defmodule Phoenix.LiveView.Channel do
11451207
end
11461208
end
11471209

1148-
defp mount(%{}, from, phx_socket) do
1210+
defp mount(%{}, from, phx_socket, _adopted_state) do
11491211
Logger.error("Mounting #{phx_socket.topic} failed because no session was provided")
11501212
GenServer.reply(from, {:error, %{reason: "stale"}})
11511213
{:stop, :shutdown, :no_session}
@@ -1171,7 +1233,8 @@ defmodule Phoenix.LiveView.Channel do
11711233
params,
11721234
from,
11731235
phx_socket,
1174-
connect_info
1236+
connect_info,
1237+
adopted_state
11751238
) do
11761239
%Session{
11771240
id: id,
@@ -1206,16 +1269,24 @@ defmodule Phoenix.LiveView.Channel do
12061269
Process.monitor(transport_pid)
12071270
load_csrf_token(endpoint, socket_session)
12081271

1209-
socket = %Socket{
1210-
endpoint: endpoint,
1211-
view: view,
1212-
transport_pid: transport_pid,
1213-
parent_pid: parent,
1214-
root_pid: root_pid || self(),
1215-
id: id,
1216-
router: router,
1217-
sticky?: params["sticky"]
1218-
}
1272+
socket =
1273+
%Socket{
1274+
endpoint: endpoint,
1275+
view: view,
1276+
transport_pid: transport_pid,
1277+
parent_pid: parent,
1278+
root_pid: root_pid || self(),
1279+
id: id,
1280+
router: router,
1281+
sticky?: params["sticky"]
1282+
}
1283+
# For adoption, we only need to copy assigns and private.
1284+
# The state already contains existing fingerprints and
1285+
# components, but we need to do a full render anyway, since
1286+
# the client needs the full diff and the connected mount may
1287+
# have changed assigns. Unchanged expressions won't be executed.
1288+
|> maybe_copy_adopted(adopted_state, :private)
1289+
|> maybe_copy_adopted(adopted_state, :assigns)
12191290

12201291
{params, host_uri, action} =
12211292
case route do
@@ -1237,7 +1308,7 @@ defmodule Phoenix.LiveView.Channel do
12371308
socket
12381309
|> load_layout(route)
12391310
|> Utils.maybe_call_live_view_mount!(view, params, merged_session, url)
1240-
|> build_state(phx_socket)
1311+
|> build_state(phx_socket, adopted_state)
12411312
|> maybe_call_mount_handle_params(router, url, params)
12421313
|> reply_mount(from, verified, route)
12431314
|> maybe_subscribe_to_live_reload()
@@ -1276,6 +1347,12 @@ defmodule Phoenix.LiveView.Channel do
12761347
end
12771348
end
12781349

1350+
defp maybe_copy_adopted(socket, nil, _), do: socket
1351+
1352+
defp maybe_copy_adopted(socket, adopted_state, key) do
1353+
Map.put(socket, key, Map.fetch!(adopted_state.socket, key))
1354+
end
1355+
12791356
defp verify_flash(endpoint, %Session{} = verified, flash_token, connect_params) do
12801357
cond do
12811358
# flash_token is given by the client on live_redirects and has higher priority.
@@ -1446,18 +1523,24 @@ defmodule Phoenix.LiveView.Channel do
14461523
end
14471524
end
14481525

1449-
defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket) do
1450-
%{
1526+
defp build_state(%Socket{} = lv_socket, %Phoenix.Socket{} = phx_socket, adopted_state) do
1527+
base =
1528+
adopted_state ||
1529+
%{
1530+
components: Diff.new_components(),
1531+
fingerprints: Diff.new_fingerprints(),
1532+
initial_diff: nil
1533+
}
1534+
1535+
Map.merge(base, %{
14511536
join_ref: phx_socket.join_ref,
14521537
serializer: phx_socket.serializer,
14531538
socket: lv_socket,
14541539
topic: phx_socket.topic,
1455-
components: Diff.new_components(),
1456-
fingerprints: Diff.new_fingerprints(),
14571540
redirect_count: 0,
14581541
upload_names: %{},
14591542
upload_pids: %{}
1460-
}
1543+
})
14611544
end
14621545

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

lib/phoenix_live_view/controller.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ defmodule Phoenix.LiveView.Controller do
3737
"""
3838
def live_render(%Plug.Conn{} = conn, view, opts \\ []) do
3939
case LiveView.Static.render(conn, view, opts) do
40-
{:ok, content, socket_assigns} ->
40+
{:ok, content} ->
4141
conn
4242
|> Plug.Conn.fetch_query_params()
4343
|> ensure_format()
4444
|> Phoenix.Controller.put_view(LiveView.Static)
4545
|> Phoenix.Controller.render(
4646
:template,
47-
Map.merge(socket_assigns, %{content: content, live_module: view})
47+
%{content: content}
4848
)
4949

5050
{:stop, %Socket{redirected: {:redirect, %{status: status} = opts}} = socket} ->

0 commit comments

Comments
 (0)