Skip to content

proof of concept for channel adoption#4318

Draft
SteffenDE wants to merge 3 commits into
mainfrom
sd-adopt
Draft

proof of concept for channel adoption#4318
SteffenDE wants to merge 3 commits into
mainfrom
sd-adopt

Conversation

@SteffenDE

@SteffenDE SteffenDE commented Jun 26, 2026

Copy link
Copy Markdown
Member

Working towards #4317.

Requires phoenixframework/phoenix#6738.


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.

Comment on lines +194 to +201
fn ->
call_mount_and_handle_params!(
socket,
view,
mount_session,
conn.params,
request_url
)

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.

I did this to reuse the existing dead render path, but it doesn't feel very clean

Comment on lines +1174 to +1180
error: ({ reason }) => {
if (reason === "invalid adoption") {
this.channel.leave();
this.channel = this.buildChannel();
this.join();
}
},

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.

# 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.

@SteffenDE

Copy link
Copy Markdown
Member Author

Demo:

Application.put_env(:phoenix, Example.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 5001],
  adapter: Bandit.PhoenixAdapter,
  server: true,
  live_view: [signing_salt: "aaaaaaaa"],
  secret_key_base: String.duplicate("a", 64)
)

Mix.install([
  {:bandit, "~> 1.8"},
  {:jason, "~> 1.2"},
  {:phoenix, github: "phoenixframework/phoenix", branch: "sd-adopt", override: true},
  {:phoenix_html, "~> 4.1"},
  {:phoenix_live_view,
   github: "phoenixframework/phoenix_live_view", branch: "sd-adopt", override: true}
], force: true)

# if you're trying to test a specific LV commit, it may be necessary to manually build
# the JS assets. To do this, uncomment the following lines:
# this needs mix and npm available in your path!
#
path = Phoenix.LiveView.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["ci"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())

path = Phoenix.__info__(:compile)[:source] |> Path.dirname() |> Path.join("../")
System.cmd("mix", ["deps.get"], cd: path, into: IO.binstream())
System.cmd("npm", ["ci"], cd: Path.join(path, "./assets"), into: IO.binstream())
System.cmd("mix", ["assets.build"], cd: path, into: IO.binstream())

defmodule Example.ErrorView do
  def render(template, _), do: Phoenix.Controller.status_message_from_template(template)
end

defmodule Example.HomeLive do
  use Phoenix.LiveView, layout: {__MODULE__, :live}, adoptable: true

  def mount(_params, _session, socket) do
    {:ok, socket |> assign(:count, 0) |> assign(:connected, connected?(socket)) |> assign_new(:load_count, fn -> LoadCount.inc() end)}
  end

  def render("live.html", assigns) do
    ~H"""
    <script src="/assets/phoenix/phoenix.js">
    </script>
    <script src="/assets/phoenix_live_view/phoenix_live_view.js">
    </script>
    <script src="/assets/phoenix_html/phoenix_html.js">
    </script>
    <%!-- uncomment to use Tailwind --%>
    <script src="https://cdn.tailwindcss.com"></script>
    <script>
      let liveSocket = new window.LiveView.LiveSocket("/live", window.Phoenix.Socket);
      liveSocket.connect();
    </script>
    <style>
      * { font-size: 1.1em; }
    </style>
    {@inner_content}
    """
  end

  def render(assigns) do
    ~H"""
    <div class="max-w-lg p-4 mx-auto space-y-4">
      <p>This LiveView has been loaded <%= @load_count %> times. If adoption works, this value should incrase by exactly one when you refresh the page.</p>

      <p>The mount callback runs twice, but you can skip loading data again with assign_new.</p>

      {@count}
      <button phx-click="inc" class="p-4 border">+</button>
      <button phx-click="dec" class="p-4 border">-</button>
    </div>
    """
  end

  def handle_event("inc", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count + 1)}
  end

  def handle_event("dec", _params, socket) do
    {:noreply, assign(socket, :count, socket.assigns.count - 1)}
  end
end

defmodule Example.Router do
  use Phoenix.Router
  import Phoenix.LiveView.Router

  pipeline :browser do
    plug :accepts, ["html"]
  end

  scope "/", Example do
    pipe_through :browser

    live "/", HomeLive, :index
  end
end

defmodule Example.Endpoint do
  use Phoenix.Endpoint, otp_app: :phoenix

  socket "/live", Phoenix.LiveView.Socket

  plug Plug.Static, from: {:phoenix, "priv/static"}, at: "/assets/phoenix"
  plug Plug.Static, from: {:phoenix_live_view, "priv/static"}, at: "/assets/phoenix_live_view"
  plug Plug.Static, from: {:phoenix_html, "priv/static"}, at: "/assets/phoenix_html"

  plug Example.Router
end

defmodule LoadCount do
  use Agent

  def start_link(_opts) do
    Agent.start_link(fn -> 0 end, name: __MODULE__)
  end

  def inc() do
    Agent.get_and_update(__MODULE__, fn state -> {state + 1, state + 1} end)
  end
end

{:ok, _} = LoadCount.start_link([])

{:ok, _} = Supervisor.start_link([Example.Endpoint], strategy: :one_for_one)
Process.sleep(:infinity)

@SteffenDE

Copy link
Copy Markdown
Member Author

This does not handle nested LiveViews yet.

@SteffenDE SteffenDE requested a review from josevalim June 29, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant