From ba59e7ec66bec89d8ee0aa6349798a8a47005fe7 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Wed, 5 Mar 2025 11:30:05 +0100 Subject: [PATCH 1/3] add auto_connect option to LiveView mount options to specify that a LiveView should not automatically connect on dead render. When navigated to and there's already a connection established, this option has no effect. See https://elixirforum.com/t/liveview-feature-to-cancel-second-render/67770 --- assets/js/phoenix_live_view/constants.js | 1 + assets/js/phoenix_live_view/live_socket.js | 3 +- lib/phoenix_live_view.ex | 7 ++ lib/phoenix_live_view/static.ex | 6 ++ lib/phoenix_live_view/utils.ex | 10 ++- test/e2e/support/lifecycle.ex | 23 +++++++ test/e2e/test_helper.exs | 1 + test/e2e/tests/lifecycle.spec.js | 75 ++++++++++++++++++++++ 8 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 test/e2e/support/lifecycle.ex create mode 100644 test/e2e/tests/lifecycle.spec.js diff --git a/assets/js/phoenix_live_view/constants.js b/assets/js/phoenix_live_view/constants.js index 34b9a31e5c..f17c837cd0 100644 --- a/assets/js/phoenix_live_view/constants.js +++ b/assets/js/phoenix_live_view/constants.js @@ -37,6 +37,7 @@ export const PHX_ERROR_CLASS = "phx-error"; export const PHX_CLIENT_ERROR_CLASS = "phx-client-error"; export const PHX_SERVER_ERROR_CLASS = "phx-server-error"; export const PHX_PARENT_ID = "data-phx-parent-id"; +export const PHX_AUTO_CONNECT = "data-phx-auto-connect"; export const PHX_MAIN = "data-phx-main"; export const PHX_ROOT_ID = "data-phx-root-id"; export const PHX_VIEWPORT_TOP = "viewport-top"; diff --git a/assets/js/phoenix_live_view/live_socket.js b/assets/js/phoenix_live_view/live_socket.js index 9482c97131..13dc95c98d 100644 --- a/assets/js/phoenix_live_view/live_socket.js +++ b/assets/js/phoenix_live_view/live_socket.js @@ -20,6 +20,7 @@ import { PHX_PARENT_ID, PHX_VIEW_SELECTOR, PHX_ROOT_ID, + PHX_AUTO_CONNECT, PHX_THROTTLE, PHX_TRACK_UPLOADS, PHX_SESSION, @@ -417,7 +418,7 @@ export default class LiveSocket { let rootsFound = false; DOM.all( document, - `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}])`, + `${PHX_VIEW_SELECTOR}:not([${PHX_PARENT_ID}]):not([${PHX_AUTO_CONNECT}="false"])`, (rootEl) => { if (!this.getRootById(rootEl.id)) { const view = this.newRootView(rootEl); diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index a64a15b6ff..0457249efa 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -217,6 +217,13 @@ defmodule Phoenix.LiveView do this option will override any layout previously set via `Phoenix.LiveView.Router.live_session/2` or on `use Phoenix.LiveView` + * `:auto_connect` - if false, instructs the LiveView JavaScript client + to not automatically connect to the server on dead render. + This is useful when you have a static page that does not require + any connected functionality, but should render over the existing + connection when navigating from an already connected LiveView. + Defaults to `true`. + """ @callback mount( params :: unsigned_params() | :not_mounted_at_router, diff --git a/lib/phoenix_live_view/static.ex b/lib/phoenix_live_view/static.ex index 4ec586e71e..e283002ce6 100644 --- a/lib/phoenix_live_view/static.ex +++ b/lib/phoenix_live_view/static.ex @@ -161,6 +161,12 @@ defmodule Phoenix.LiveView.Static do data_attrs = if(router, do: [phx_main: true], else: []) ++ data_attrs + data_attrs = + if(not Map.get(socket.private, :auto_connect, true), + do: [phx_auto_connect: "false"], + else: [] + ) ++ data_attrs + attrs = [ {:id, socket.id}, {:data, data_attrs} diff --git a/lib/phoenix_live_view/utils.ex b/lib/phoenix_live_view/utils.ex index 245a89428b..2afc38c084 100644 --- a/lib/phoenix_live_view/utils.ex +++ b/lib/phoenix_live_view/utils.ex @@ -6,7 +6,7 @@ defmodule Phoenix.LiveView.Utils do alias Phoenix.LiveView.{Socket, Lifecycle} # All available mount options - @mount_opts [:temporary_assigns, :layout] + @mount_opts [:temporary_assigns, :layout, :auto_connect] @max_flash_age :timer.seconds(60) @@ -437,6 +437,14 @@ defmodule Phoenix.LiveView.Utils do } end + defp handle_mount_option(%Socket{} = socket, :auto_connect, value) do + if not is_boolean(value) do + raise "the :auto_connect mount option must be a boolean, got: #{inspect(value)}" + end + + put_in(socket.private[:auto_connect], value) + end + @doc """ Calls the `handle_params/3` callback, and returns the result. diff --git a/test/e2e/support/lifecycle.ex b/test/e2e/support/lifecycle.ex new file mode 100644 index 0000000000..d4fa09d0cd --- /dev/null +++ b/test/e2e/support/lifecycle.ex @@ -0,0 +1,23 @@ +defmodule Phoenix.LiveViewTest.E2E.LifecycleLive do + use Phoenix.LiveView + + @impl Phoenix.LiveView + def mount(params, _session, socket) do + auto_connect = + case params do + %{"auto_connect" => "false"} -> false + _ -> true + end + + {:ok, socket, auto_connect: auto_connect} + end + + @impl Phoenix.LiveView + def render(assigns) do + ~H""" +
Hello!
+ <.link navigate="/lifecycle">Navigate to self (auto_connect=true) + <.link navigate="/lifecycle?auto_connect=false">Navigate to self (auto_connect=false) + """ + end +end diff --git a/test/e2e/test_helper.exs b/test/e2e/test_helper.exs index 4578a70dea..d57da17002 100644 --- a/test/e2e/test_helper.exs +++ b/test/e2e/test_helper.exs @@ -151,6 +151,7 @@ defmodule Phoenix.LiveViewTest.E2E.Router do live "/js", E2E.JsLive live "/select", E2E.SelectLive live "/components", E2E.ComponentsLive + live "/lifecycle", E2E.LifecycleLive end scope "/issues", Phoenix.LiveViewTest.E2E do diff --git a/test/e2e/tests/lifecycle.spec.js b/test/e2e/tests/lifecycle.spec.js new file mode 100644 index 0000000000..611e7d9d44 --- /dev/null +++ b/test/e2e/tests/lifecycle.spec.js @@ -0,0 +1,75 @@ +import { test, expect } from "../test-fixtures"; +import { syncLV } from "../utils"; + +test.describe("auto_connect", () => { + let webSocketEvents = []; + let networkEvents = []; + let consoleMessages = []; + + test.beforeEach(async ({ page }) => { + networkEvents = []; + webSocketEvents = []; + consoleMessages = []; + + page.on("request", (request) => + networkEvents.push({ method: request.method(), url: request.url() }), + ); + + page.on("websocket", (ws) => { + ws.on("framesent", (event) => + webSocketEvents.push({ type: "sent", payload: event.payload }), + ); + ws.on("framereceived", (event) => + webSocketEvents.push({ type: "received", payload: event.payload }), + ); + ws.on("close", () => webSocketEvents.push({ type: "close" })); + }); + + page.on("console", (msg) => consoleMessages.push(msg.text())); + }); + + test("connects by default", async ({ page }) => { + await page.goto("/lifecycle"); + await syncLV(page); + + expect(webSocketEvents).toHaveLength(2); + }); + + test("does not connect when auto_connect is false", async ({ page }) => { + await page.goto("/lifecycle?auto_connect=false"); + // eslint-disable-next-line playwright/no-networkidle + await page.waitForLoadState("networkidle"); + expect(webSocketEvents).toHaveLength(0); + }); + + test("connects when navigating to a view with auto_connect=true", async ({ + page, + }) => { + await page.goto("/lifecycle?auto_connect=false"); + // eslint-disable-next-line playwright/no-networkidle + await page.waitForLoadState("networkidle"); + expect(webSocketEvents).toHaveLength(0); + await page + .getByRole("link", { name: "Navigate to self (auto_connect=true)" }) + .click(); + await syncLV(page); + expect(webSocketEvents).toHaveLength(2); + }); + + test("stays connected when navigating to a view with auto_connect=false", async ({ + page, + }) => { + await page.goto("/lifecycle"); + await syncLV(page); + expect( + webSocketEvents.filter((e) => e.payload.includes("phx_join")), + ).toHaveLength(1); + await page + .getByRole("link", { name: "Navigate to self (auto_connect=false)" }) + .click(); + await syncLV(page); + expect( + webSocketEvents.filter((e) => e.payload.includes("phx_join")), + ).toHaveLength(2); + }); +}); From 72b6d5d6ce28f104f8066f62f92b764407ccf257 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 7 Mar 2025 16:15:10 +0100 Subject: [PATCH 2/3] Update lib/phoenix_live_view.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/phoenix_live_view.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index 0457249efa..d8882192a4 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -218,7 +218,7 @@ defmodule Phoenix.LiveView do `Phoenix.LiveView.Router.live_session/2` or on `use Phoenix.LiveView` * `:auto_connect` - if false, instructs the LiveView JavaScript client - to not automatically connect to the server on dead render. + to not automatically connect to the server on disconnected render. This is useful when you have a static page that does not require any connected functionality, but should render over the existing connection when navigating from an already connected LiveView. From a8756c0887e958777a0858cd77c7ebbaab408dd6 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Mon, 10 Mar 2025 13:00:27 +0100 Subject: [PATCH 3/3] state that all navigations are full page reloads --- lib/phoenix_live_view.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_live_view.ex b/lib/phoenix_live_view.ex index d8882192a4..74a5c7e147 100644 --- a/lib/phoenix_live_view.ex +++ b/lib/phoenix_live_view.ex @@ -222,7 +222,9 @@ defmodule Phoenix.LiveView do This is useful when you have a static page that does not require any connected functionality, but should render over the existing connection when navigating from an already connected LiveView. - Defaults to `true`. + Defaults to `true`. When navigating from a page that has `auto_connect: false` + and is not connected, all navigations perform a regular `window.location` + update, triggering a full page reload. """ @callback mount(